refactor: excel parse
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Copyright (C) 2011-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from .mtextsurrogate import MTextSurrogate
|
||||
from .tablepainter import TablePainter, CustomCell
|
||||
from .menger_sponge import MengerSponge
|
||||
from .sierpinski_pyramid import SierpinskyPyramid
|
||||
from .dimlines import LinearDimension, AngularDimension, ArcDimension, RadialDimension, dimstyles
|
||||
from .importer import Importer
|
||||
from .r12writer import r12writer
|
||||
from .mtxpl import MTextExplode
|
||||
@@ -0,0 +1,779 @@
|
||||
# Purpose: read and write AutoCAD CTB files
|
||||
# Copyright (c) 2010-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# IMPORTANT: use only standard 7-Bit ascii code
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Union,
|
||||
Optional,
|
||||
BinaryIO,
|
||||
TextIO,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Any,
|
||||
)
|
||||
import os
|
||||
from abc import abstractmethod
|
||||
from io import StringIO
|
||||
from array import array
|
||||
from struct import pack
|
||||
import zlib
|
||||
|
||||
END_STYLE_BUTT = 0
|
||||
END_STYLE_SQUARE = 1
|
||||
END_STYLE_ROUND = 2
|
||||
END_STYLE_DIAMOND = 3
|
||||
END_STYLE_OBJECT = 4
|
||||
|
||||
JOIN_STYLE_MITER = 0
|
||||
JOIN_STYLE_BEVEL = 1
|
||||
JOIN_STYLE_ROUND = 2
|
||||
JOIN_STYLE_DIAMOND = 3
|
||||
JOIN_STYLE_OBJECT = 5
|
||||
|
||||
FILL_STYLE_SOLID = 64
|
||||
FILL_STYLE_CHECKERBOARD = 65
|
||||
FILL_STYLE_CROSSHATCH = 66
|
||||
FILL_STYLE_DIAMONDS = 67
|
||||
FILL_STYLE_HORIZONTAL_BARS = 68
|
||||
FILL_STYLE_SLANT_LEFT = 69
|
||||
FILL_STYLE_SLANT_RIGHT = 70
|
||||
FILL_STYLE_SQUARE_DOTS = 71
|
||||
FILL_STYLE_VERICAL_BARS = 72
|
||||
FILL_STYLE_OBJECT = 73
|
||||
|
||||
DITHERING_ON = 1 # bit coded color_policy
|
||||
GRAYSCALE_ON = 2 # bit coded color_policy
|
||||
NAMED_COLOR = 4 # bit coded color_policy
|
||||
|
||||
AUTOMATIC = 0
|
||||
OBJECT_LINEWEIGHT = 0
|
||||
OBJECT_LINETYPE = 31
|
||||
OBJECT_COLOR = -1
|
||||
OBJECT_COLOR2 = -1006632961
|
||||
|
||||
STYLE_COUNT = 255
|
||||
|
||||
DEFAULT_LINE_WEIGHTS = [
|
||||
0.00, # 0
|
||||
0.05, # 1
|
||||
0.09, # 2
|
||||
0.10, # 3
|
||||
0.13, # 4
|
||||
0.15, # 5
|
||||
0.18, # 6
|
||||
0.20, # 7
|
||||
0.25, # 8
|
||||
0.30, # 9
|
||||
0.35, # 10
|
||||
0.40, # 11
|
||||
0.45, # 12
|
||||
0.50, # 13
|
||||
0.53, # 14
|
||||
0.60, # 15
|
||||
0.65, # 16
|
||||
0.70, # 17
|
||||
0.80, # 18
|
||||
0.90, # 19
|
||||
1.00, # 20
|
||||
1.06, # 21
|
||||
1.20, # 22
|
||||
1.40, # 23
|
||||
1.58, # 24
|
||||
2.00, # 25
|
||||
2.11, # 26
|
||||
]
|
||||
|
||||
# color_type: (thx to Rammi)
|
||||
|
||||
# Take color from layer, ignore other bytes.
|
||||
COLOR_BY_LAYER = 0xC0
|
||||
|
||||
# Take color from insertion, ignore other bytes
|
||||
COLOR_BY_BLOCK = 0xC1
|
||||
|
||||
# RGB value, other bytes are R,G,B.
|
||||
COLOR_RGB = 0xC2
|
||||
|
||||
# ACI, AutoCAD color index, other bytes are 0,0,index ???
|
||||
COLOR_ACI = 0xC3
|
||||
|
||||
|
||||
def color_name(index: int) -> str:
|
||||
return "Color_%d" % (index + 1)
|
||||
|
||||
|
||||
def get_bool(value: Union[str, bool]) -> bool:
|
||||
if isinstance(value, str):
|
||||
upperstr = value.upper()
|
||||
if upperstr == "TRUE":
|
||||
value = True
|
||||
elif upperstr == "FALSE":
|
||||
value = False
|
||||
else:
|
||||
raise ValueError("Unknown bool value '%s'." % str(value))
|
||||
return value
|
||||
|
||||
|
||||
class PlotStyle:
|
||||
def __init__(
|
||||
self,
|
||||
index: int,
|
||||
data: Optional[dict] = None,
|
||||
parent: Optional[PlotStyleTable] = None,
|
||||
):
|
||||
data = data or {}
|
||||
self.parent = parent
|
||||
self.index = int(index)
|
||||
self.name = str(data.get("name", color_name(index)))
|
||||
self.localized_name = str(data.get("localized_name", color_name(index)))
|
||||
self.description = str(data.get("description", ""))
|
||||
# do not set _color, _mode_color or _color_policy directly
|
||||
# use set_color() method, and the properties dithering and grayscale
|
||||
self._color = int(data.get("color", OBJECT_COLOR))
|
||||
self._color_type = COLOR_RGB
|
||||
if self._color != OBJECT_COLOR:
|
||||
self._mode_color = int(data.get("mode_color", self._color))
|
||||
self._color_policy = int(data.get("color_policy", DITHERING_ON))
|
||||
self.physical_pen_number = int(data.get("physical_pen_number", AUTOMATIC))
|
||||
self.virtual_pen_number = int(data.get("virtual_pen_number", AUTOMATIC))
|
||||
self.screen = int(data.get("screen", 100))
|
||||
self.linepattern_size = float(data.get("linepattern_size", 0.5))
|
||||
self.linetype = int(data.get("linetype", OBJECT_LINETYPE)) # 0 .. 30
|
||||
self.adaptive_linetype = get_bool(data.get("adaptive_linetype", True))
|
||||
|
||||
# lineweight index
|
||||
self.lineweight = int(data.get("lineweight", OBJECT_LINEWEIGHT))
|
||||
self.end_style = int(data.get("end_style", END_STYLE_OBJECT))
|
||||
self.join_style = int(data.get("join_style", JOIN_STYLE_OBJECT))
|
||||
self.fill_style = int(data.get("fill_style", FILL_STYLE_OBJECT))
|
||||
|
||||
@property
|
||||
def color(self) -> Optional[tuple[int, int, int]]:
|
||||
"""Get style color as ``(r, g, b)`` tuple or ``None``, if style has
|
||||
object color.
|
||||
"""
|
||||
if self.has_object_color():
|
||||
return None # object color
|
||||
else:
|
||||
return int2color(self._mode_color)[:3]
|
||||
|
||||
@color.setter
|
||||
def color(self, rgb: tuple[int, int, int]) -> None:
|
||||
"""Set color as RGB values."""
|
||||
r, g, b = rgb
|
||||
# when defining a user-color, `mode_color` represents the real
|
||||
# true_color as (r, g, b) tuple and color_type = COLOR_RGB (0xC2) as
|
||||
# highest byte, the `color` value calculated for a user-color is not a
|
||||
# (r, g, b) tuple and has color_type = COLOR_ACI (0xC3) (sometimes), set
|
||||
# for `color` the same value as for `mode_color`, because AutoCAD
|
||||
# corrects the `color` value by itself.
|
||||
self._mode_color = mode_color2int(r, g, b, color_type=self._color_type)
|
||||
self._color = self._mode_color
|
||||
|
||||
@property
|
||||
def color_type(self):
|
||||
if self.has_object_color():
|
||||
return None # object color
|
||||
else:
|
||||
return self._color_type
|
||||
|
||||
@color_type.setter
|
||||
def color_type(self, value: int):
|
||||
self._color_type = value
|
||||
|
||||
def set_object_color(self) -> None:
|
||||
"""Set color to object color."""
|
||||
self._color = OBJECT_COLOR
|
||||
self._mode_color = OBJECT_COLOR
|
||||
|
||||
def set_lineweight(self, lineweight: float) -> None:
|
||||
"""Set `lineweight` in millimeters. Use ``0.0`` to set lineweight by
|
||||
object.
|
||||
"""
|
||||
assert self.parent is not None
|
||||
self.lineweight = self.parent.get_lineweight_index(lineweight)
|
||||
|
||||
def get_lineweight(self) -> float:
|
||||
"""Returns the lineweight in millimeters or `0.0` for use entity
|
||||
lineweight.
|
||||
"""
|
||||
assert self.parent is not None
|
||||
return self.parent.lineweights[self.lineweight]
|
||||
|
||||
def has_object_color(self) -> bool:
|
||||
"""``True`` if style has object color."""
|
||||
return self._color in (OBJECT_COLOR, OBJECT_COLOR2)
|
||||
|
||||
@property
|
||||
def aci(self) -> int:
|
||||
""":ref:`ACI` in range from ``1`` to ``255``. Has no meaning for named
|
||||
plot styles. (int)
|
||||
"""
|
||||
return self.index + 1
|
||||
|
||||
@property
|
||||
def dithering(self) -> bool:
|
||||
"""Depending on the capabilities of your plotter, dithering approximates
|
||||
the colors with dot patterns. When this option is ``False``, the colors
|
||||
are mapped to the nearest color, resulting in a smaller range of
|
||||
colors when plotting.
|
||||
|
||||
Dithering is available only whether you select the 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
|
||||
@@ -0,0 +1,297 @@
|
||||
# Copyright (c) 2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterator, Iterable, Optional
|
||||
import ezdxf
|
||||
from ezdxf.addons.xqt import (
|
||||
QtWidgets,
|
||||
QtGui,
|
||||
QAction,
|
||||
QMessageBox,
|
||||
QFileDialog,
|
||||
Qt,
|
||||
QModelIndex,
|
||||
)
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.entities import Body
|
||||
from ezdxf.lldxf.const import DXFStructureError
|
||||
from .data import AcisData, BinaryAcisData, TextAcisData
|
||||
|
||||
|
||||
APP_NAME = "ACIS Structure Browser"
|
||||
BROWSER_WIDTH = 1024
|
||||
BROWSER_HEIGHT = 768
|
||||
SELECTOR_WIDTH_FACTOR = 0.20
|
||||
FONT_FAMILY = "monospaced"
|
||||
|
||||
|
||||
def make_font():
|
||||
font = QtGui.QFont(FONT_FAMILY)
|
||||
font.setStyleHint(QtGui.QFont.Monospace)
|
||||
return font
|
||||
|
||||
|
||||
class AcisStructureBrowser(QtWidgets.QMainWindow):
|
||||
def __init__(
|
||||
self,
|
||||
filename: str = "",
|
||||
handle: str = "",
|
||||
):
|
||||
super().__init__()
|
||||
self.doc: Optional[Drawing] = None
|
||||
self.acis_entities: list[AcisData] = []
|
||||
self.current_acis_entity = AcisData()
|
||||
self.entity_selector = self.make_entity_selector()
|
||||
self.acis_content_viewer = self.make_content_viewer()
|
||||
self.statusbar = QtWidgets.QStatusBar(self)
|
||||
self.setup_actions()
|
||||
self.setup_menu()
|
||||
|
||||
if filename:
|
||||
self.load_dxf(filename)
|
||||
else:
|
||||
self.setWindowTitle(APP_NAME)
|
||||
|
||||
self.setStatusBar(self.statusbar)
|
||||
self.setCentralWidget(self.make_central_widget())
|
||||
self.resize(BROWSER_WIDTH, BROWSER_HEIGHT)
|
||||
self.connect_slots()
|
||||
if handle:
|
||||
try:
|
||||
int(handle, 16)
|
||||
except ValueError:
|
||||
msg = f"Given handle is not a hex value: '{handle}'"
|
||||
self.statusbar.showMessage(msg)
|
||||
print(msg)
|
||||
else:
|
||||
if not self.goto_handle(handle):
|
||||
msg = f"Handle '{handle}' not found."
|
||||
self.statusbar.showMessage(msg)
|
||||
print(msg)
|
||||
|
||||
def make_entity_selector(self):
|
||||
return QtWidgets.QListWidget(self)
|
||||
|
||||
def make_content_viewer(self):
|
||||
viewer = QtWidgets.QPlainTextEdit(self)
|
||||
viewer.setReadOnly(True)
|
||||
viewer.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||
return viewer
|
||||
|
||||
def make_central_widget(self):
|
||||
container = QtWidgets.QSplitter(Qt.Horizontal)
|
||||
container.addWidget(self.entity_selector)
|
||||
container.addWidget(self.acis_content_viewer)
|
||||
selector_width = int(BROWSER_WIDTH * SELECTOR_WIDTH_FACTOR)
|
||||
entity_view_width = BROWSER_WIDTH - selector_width
|
||||
container.setSizes([selector_width, entity_view_width])
|
||||
container.setCollapsible(0, False)
|
||||
container.setCollapsible(1, False)
|
||||
return container
|
||||
|
||||
def connect_slots(self):
|
||||
self.entity_selector.clicked.connect(self.acis_entity_activated)
|
||||
self.entity_selector.activated.connect(self.acis_entity_activated)
|
||||
|
||||
# noinspection PyAttributeOutsideInit
|
||||
def setup_actions(self):
|
||||
self._open_action = self.make_action(
|
||||
"&Open DXF File...", self.open_dxf, shortcut="Ctrl+O"
|
||||
)
|
||||
self._reload_action = self.make_action(
|
||||
"Reload DXF File",
|
||||
self.reload_dxf,
|
||||
shortcut="Ctrl+R",
|
||||
)
|
||||
self._export_entity_action = self.make_action(
|
||||
"&Export Current Entity View...",
|
||||
self.export_entity,
|
||||
shortcut="Ctrl+E",
|
||||
)
|
||||
self._export_raw_data_action = self.make_action(
|
||||
"&Export Raw SAT/SAB Data...",
|
||||
self.export_raw_entity,
|
||||
shortcut="Ctrl+W",
|
||||
)
|
||||
self._quit_action = self.make_action(
|
||||
"&Quit", self.close, shortcut="Ctrl+Q"
|
||||
)
|
||||
|
||||
def make_action(
|
||||
self,
|
||||
name,
|
||||
slot,
|
||||
*,
|
||||
shortcut: str = "",
|
||||
tip: str = "",
|
||||
) -> QAction:
|
||||
action = QAction(name, self)
|
||||
if shortcut:
|
||||
action.setShortcut(shortcut)
|
||||
if tip:
|
||||
action.setToolTip(tip)
|
||||
action.triggered.connect(slot)
|
||||
return action
|
||||
|
||||
def setup_menu(self):
|
||||
menu = self.menuBar()
|
||||
file_menu = menu.addMenu("&File")
|
||||
file_menu.addAction(self._open_action)
|
||||
file_menu.addAction(self._reload_action)
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(self._export_entity_action)
|
||||
file_menu.addAction(self._export_raw_data_action)
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(self._quit_action)
|
||||
|
||||
def open_dxf(self):
|
||||
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||
self,
|
||||
caption="Select DXF file",
|
||||
filter="DXF Documents (*.dxf *.DXF)",
|
||||
)
|
||||
if path:
|
||||
self.load_dxf(path)
|
||||
|
||||
def load_dxf(self, path: str):
|
||||
try:
|
||||
doc = ezdxf.readfile(path)
|
||||
except IOError as e:
|
||||
QMessageBox.critical(self, "Loading Error", str(e))
|
||||
return
|
||||
except DXFStructureError as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"DXF Structure Error",
|
||||
f'Invalid DXF file "{path}": {str(e)}',
|
||||
)
|
||||
return
|
||||
entities = list(get_acis_entities(doc))
|
||||
if len(entities):
|
||||
self.doc = doc
|
||||
self.set_acis_entities(entities)
|
||||
self.update_title(path)
|
||||
self.statusbar.showMessage(self.make_loading_message())
|
||||
else:
|
||||
msg = f"DXF file '{path}' contains no ACIS data"
|
||||
QMessageBox.information(self, "Loading Error", msg) # type: ignore
|
||||
|
||||
def make_loading_message(self) -> str:
|
||||
assert self.doc is not None
|
||||
dxfversion = self.doc.dxfversion
|
||||
acis_type = "SAB" if dxfversion >= "AC1027" else "SAT"
|
||||
return f"Loaded DXF file has version {self.doc.acad_release}/{dxfversion}" \
|
||||
f" and contains {acis_type} data"
|
||||
|
||||
def set_acis_entities(self, entities: list[AcisData]):
|
||||
self.acis_entities = entities
|
||||
self.update_entity_selector(entities)
|
||||
self.set_current_acis_entity(entities[0])
|
||||
|
||||
def reload_dxf(self):
|
||||
try:
|
||||
index = self.acis_entities.index(self.current_acis_entity)
|
||||
except IndexError:
|
||||
index = -1
|
||||
self.load_dxf(self.doc.filename)
|
||||
if index > 0:
|
||||
self.set_current_acis_entity(self.acis_entities[index])
|
||||
|
||||
def export_entity(self):
|
||||
dxf_entity = self.get_current_dxf_entity()
|
||||
if dxf_entity is None:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
caption="Export Current Entity View",
|
||||
dir=f"{dxf_entity.dxftype()}-{dxf_entity.dxf.handle}.txt",
|
||||
filter="Text Files (*.txt *.TXT)",
|
||||
)
|
||||
if path:
|
||||
write_data(self.current_acis_entity, path)
|
||||
|
||||
def export_raw_entity(self):
|
||||
dxf_entity = self.get_current_dxf_entity()
|
||||
if dxf_entity is None:
|
||||
return
|
||||
filename = f"{dxf_entity.dxftype()}-{dxf_entity.dxf.handle}"
|
||||
sab = dxf_entity.has_binary_data
|
||||
if sab:
|
||||
filter_ = "Standard ACIS Binary Files (*.sab *.SAB)"
|
||||
filename += ".sab"
|
||||
else:
|
||||
filter_ = "Standard ACIS Text Files (*.sat *.SAT)"
|
||||
filename += ".sat"
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
caption="Export ACIS Raw Data",
|
||||
dir=filename,
|
||||
filter=filter_,
|
||||
)
|
||||
if path:
|
||||
if sab:
|
||||
with open(path, "wb") as fp:
|
||||
fp.write(dxf_entity.sab)
|
||||
else:
|
||||
with open(path, "wt") as fp:
|
||||
fp.write("\n".join(dxf_entity.sat))
|
||||
|
||||
def get_current_dxf_entity(self) -> Optional[Body]:
|
||||
current = self.current_acis_entity
|
||||
if not current.handle or self.doc is None:
|
||||
return None
|
||||
return self.doc.entitydb.get(current.handle) # type: ignore
|
||||
|
||||
def update_title(self, path: str):
|
||||
self.setWindowTitle(f"{APP_NAME} - {path}")
|
||||
|
||||
def acis_entity_activated(self, index: QModelIndex):
|
||||
if len(self.acis_entities) == 0:
|
||||
return
|
||||
try:
|
||||
self.set_current_acis_entity(self.acis_entities[index.row()])
|
||||
except IndexError:
|
||||
self.set_current_acis_entity(self.acis_entities[0])
|
||||
|
||||
def set_current_acis_entity(self, entity: AcisData):
|
||||
if entity:
|
||||
self.current_acis_entity = entity
|
||||
self.update_acis_content_viewer(entity)
|
||||
|
||||
def update_acis_content_viewer(self, entity: AcisData):
|
||||
viewer = self.acis_content_viewer
|
||||
viewer.clear()
|
||||
viewer.setPlainText("\n".join(entity.lines))
|
||||
|
||||
def update_entity_selector(self, entities: Iterable[AcisData]):
|
||||
viewer = self.entity_selector
|
||||
viewer.clear()
|
||||
viewer.addItems([e.name for e in entities])
|
||||
|
||||
def goto_handle(self, handle: str) -> bool:
|
||||
for entity in self.acis_entities:
|
||||
if entity.handle == handle:
|
||||
self.set_current_acis_entity(entity)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_acis_entities(doc: Drawing) -> Iterator[AcisData]:
|
||||
for e in doc.entitydb.values():
|
||||
if isinstance(e, Body):
|
||||
handle = e.dxf.handle
|
||||
name = f"<{handle}> {e.dxftype()}"
|
||||
if e.has_binary_data:
|
||||
yield BinaryAcisData(e.sab, name, handle)
|
||||
else:
|
||||
yield TextAcisData(e.sat, name, handle)
|
||||
|
||||
|
||||
def write_data(entity: AcisData, path: str):
|
||||
try:
|
||||
with open(path, "wt") as fp:
|
||||
fp.write("\n".join(entity.lines))
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Copyright (c) 2022-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterator, Sequence
|
||||
|
||||
from ezdxf.acis.sat import parse_sat, SatEntity
|
||||
from ezdxf.acis.sab import parse_sab, SabEntity
|
||||
|
||||
|
||||
class AcisData:
|
||||
def __init__(self, name: str = "unknown", handle: str = ""):
|
||||
self.lines: list[str] = []
|
||||
self.name: str = name
|
||||
self.handle: str = handle
|
||||
|
||||
|
||||
class BinaryAcisData(AcisData):
|
||||
def __init__(self, data: bytes, name: str, handle: str):
|
||||
super().__init__(name, handle)
|
||||
self.lines = list(make_sab_records(data))
|
||||
|
||||
|
||||
class TextAcisData(AcisData):
|
||||
def __init__(self, data: Sequence[str], name: str, handle: str):
|
||||
super().__init__(name, handle)
|
||||
self.lines = list(make_sat_records(data))
|
||||
|
||||
|
||||
def ptr_str(e):
|
||||
return "~" if e.is_null_ptr else str(e)
|
||||
|
||||
|
||||
def make_sat_records(data: Sequence[str]) -> Iterator[str]:
|
||||
builder = parse_sat(data)
|
||||
yield from builder.header.dumps()
|
||||
builder.reset_ids()
|
||||
for entity in builder.entities:
|
||||
content = [str(entity)]
|
||||
content.append(ptr_str(entity.attributes))
|
||||
for field in entity.data:
|
||||
if isinstance(field, SatEntity):
|
||||
content.append(ptr_str(field))
|
||||
else:
|
||||
content.append(field)
|
||||
yield " ".join(content)
|
||||
|
||||
|
||||
def make_sab_records(data: bytes) -> Iterator[str]:
|
||||
builder = parse_sab(data)
|
||||
yield from builder.header.dumps()
|
||||
builder.reset_ids()
|
||||
for entity in builder.entities:
|
||||
content = [str(entity)]
|
||||
content.append(ptr_str(entity.attributes))
|
||||
for tag in entity.data:
|
||||
if isinstance(tag.value, SabEntity):
|
||||
content.append(ptr_str(tag.value))
|
||||
else:
|
||||
content.append(f"{tag.value}<{tag.tag}>")
|
||||
yield " ".join(content)
|
||||
@@ -0,0 +1,716 @@
|
||||
# Source package: "py3dbp" hosted on PyPI
|
||||
# (c) Enzo Ruiz Pelaez
|
||||
# https://github.com/enzoruiz/3dbinpacking
|
||||
# License: MIT License
|
||||
# Credits:
|
||||
# - https://github.com/enzoruiz/3dbinpacking/blob/master/erick_dube_507-034.pdf
|
||||
# - https://github.com/gedex/bp3d - implementation in Go
|
||||
# - https://github.com/bom-d-van/binpacking - implementation in Go
|
||||
#
|
||||
# ezdxf add-on:
|
||||
# License: MIT License
|
||||
# (c) 2022, Manfred Moitzi:
|
||||
# - refactoring
|
||||
# - type annotations
|
||||
# - adaptations:
|
||||
# - removing Decimal class usage
|
||||
# - utilizing ezdxf.math.BoundingBox for intersection checks
|
||||
# - removed non-distributing mode; copy packer and use different bins for each copy
|
||||
# - additions:
|
||||
# - Item.get_transformation()
|
||||
# - shuffle_pack()
|
||||
# - pack_item_subset()
|
||||
# - DXF exporter for debugging
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Iterable,
|
||||
TYPE_CHECKING,
|
||||
TypeVar,
|
||||
Optional,
|
||||
)
|
||||
from enum import Enum, auto
|
||||
import copy
|
||||
import math
|
||||
import random
|
||||
|
||||
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.math import (
|
||||
Vec2,
|
||||
Vec3,
|
||||
UVec,
|
||||
BoundingBox,
|
||||
BoundingBox2d,
|
||||
AbstractBoundingBox,
|
||||
Matrix44,
|
||||
)
|
||||
from . import genetic_algorithm as ga
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.eztypes import GenericLayoutType
|
||||
|
||||
__all__ = [
|
||||
"Item",
|
||||
"FlatItem",
|
||||
"Box", # contains Item
|
||||
"Envelope", # contains FlatItem
|
||||
"AbstractPacker",
|
||||
"Packer",
|
||||
"FlatPacker",
|
||||
"RotationType",
|
||||
"PickStrategy",
|
||||
"shuffle_pack",
|
||||
"pack_item_subset",
|
||||
"export_dxf",
|
||||
]
|
||||
|
||||
UNLIMITED_WEIGHT = 1e99
|
||||
T = TypeVar("T")
|
||||
PI_2 = math.pi / 2
|
||||
|
||||
|
||||
class RotationType(Enum):
|
||||
"""Rotation type of an item:
|
||||
|
||||
- W = width
|
||||
- H = height
|
||||
- D = depth
|
||||
|
||||
"""
|
||||
|
||||
WHD = auto()
|
||||
HWD = auto()
|
||||
HDW = auto()
|
||||
DHW = auto()
|
||||
DWH = auto()
|
||||
WDH = auto()
|
||||
|
||||
|
||||
class Axis(Enum):
|
||||
WIDTH = auto()
|
||||
HEIGHT = auto()
|
||||
DEPTH = auto()
|
||||
|
||||
|
||||
class PickStrategy(Enum):
|
||||
"""Order of how to pick items for placement."""
|
||||
|
||||
SMALLER_FIRST = auto()
|
||||
BIGGER_FIRST = auto()
|
||||
SHUFFLE = auto()
|
||||
|
||||
|
||||
START_POSITION: tuple[float, float, float] = (0, 0, 0)
|
||||
|
||||
|
||||
class Item:
|
||||
"""3D container item."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payload,
|
||||
width: float,
|
||||
height: float,
|
||||
depth: float,
|
||||
weight: float = 0.0,
|
||||
):
|
||||
self.payload = payload # arbitrary associated Python object
|
||||
self.width = float(width)
|
||||
self.height = float(height)
|
||||
self.depth = float(depth)
|
||||
self.weight = float(weight)
|
||||
self._rotation_type = RotationType.WHD
|
||||
self._position = START_POSITION
|
||||
self._bbox: Optional[AbstractBoundingBox] = None
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{str(self.payload)}({self.width}x{self.height}x{self.depth}, "
|
||||
f"weight: {self.weight}) pos({str(self.position)}) "
|
||||
f"rt({self.rotation_type}) vol({self.get_volume()})"
|
||||
)
|
||||
|
||||
def copy(self):
|
||||
"""Returns a copy, all copies have a reference to the same payload
|
||||
object.
|
||||
"""
|
||||
return copy.copy(self) # shallow copy
|
||||
|
||||
@property
|
||||
def bbox(self) -> AbstractBoundingBox:
|
||||
if self._bbox is None:
|
||||
self._update_bbox()
|
||||
return self._bbox # type: ignore
|
||||
|
||||
def _update_bbox(self) -> None:
|
||||
v1 = Vec3(self._position)
|
||||
self._bbox = BoundingBox([v1, v1 + Vec3(self.get_dimension())])
|
||||
|
||||
def _taint(self):
|
||||
self._bbox = None
|
||||
|
||||
@property
|
||||
def rotation_type(self) -> RotationType:
|
||||
return self._rotation_type
|
||||
|
||||
@rotation_type.setter
|
||||
def rotation_type(self, value: RotationType) -> None:
|
||||
self._rotation_type = value
|
||||
self._taint()
|
||||
|
||||
@property
|
||||
def position(self) -> tuple[float, float, float]:
|
||||
"""Returns the position of then lower left corner of the item in the
|
||||
container, the lower left corner is the origin (0, 0, 0).
|
||||
"""
|
||||
return self._position
|
||||
|
||||
@position.setter
|
||||
def position(self, value: tuple[float, float, float]) -> None:
|
||||
self._position = value
|
||||
self._taint()
|
||||
|
||||
def get_volume(self) -> float:
|
||||
"""Returns the volume of the item."""
|
||||
return self.width * self.height * self.depth
|
||||
|
||||
def get_dimension(self) -> tuple[float, float, float]:
|
||||
"""Returns the item dimension according the :attr:`rotation_type`."""
|
||||
rt = self.rotation_type
|
||||
if rt == RotationType.WHD:
|
||||
return self.width, self.height, self.depth
|
||||
elif rt == RotationType.HWD:
|
||||
return self.height, self.width, self.depth
|
||||
elif rt == RotationType.HDW:
|
||||
return self.height, self.depth, self.width
|
||||
elif rt == RotationType.DHW:
|
||||
return self.depth, self.height, self.width
|
||||
elif rt == RotationType.DWH:
|
||||
return self.depth, self.width, self.height
|
||||
elif rt == RotationType.WDH:
|
||||
return self.width, self.depth, self.height
|
||||
raise ValueError(rt)
|
||||
|
||||
def get_transformation(self) -> Matrix44:
|
||||
"""Returns the transformation matrix to transform the source entity
|
||||
located with the minimum extension corner of its bounding box in
|
||||
(0, 0, 0) to the final location including the required rotation.
|
||||
"""
|
||||
x, y, z = self.position
|
||||
rt = self.rotation_type
|
||||
if rt == RotationType.WHD: # width, height, depth
|
||||
return Matrix44.translate(x, y, z)
|
||||
if rt == RotationType.HWD: # height, width, depth
|
||||
return Matrix44.z_rotate(PI_2) @ Matrix44.translate(
|
||||
x + self.height, y, z
|
||||
)
|
||||
if rt == RotationType.HDW: # height, depth, width
|
||||
return Matrix44.xyz_rotate(PI_2, 0, PI_2) @ Matrix44.translate(
|
||||
x + self.height, y + self.depth, z
|
||||
)
|
||||
if rt == RotationType.DHW: # depth, height, width
|
||||
return Matrix44.y_rotate(-PI_2) @ Matrix44.translate(
|
||||
x + self.depth, y, z
|
||||
)
|
||||
if rt == RotationType.DWH: # depth, width, height
|
||||
return Matrix44.xyz_rotate(0, PI_2, PI_2) @ Matrix44.translate(
|
||||
x, y, z
|
||||
)
|
||||
if rt == RotationType.WDH: # width, depth, height
|
||||
return Matrix44.x_rotate(PI_2) @ Matrix44.translate(
|
||||
x, y + self.depth, z
|
||||
)
|
||||
raise TypeError(rt)
|
||||
|
||||
|
||||
class FlatItem(Item):
|
||||
"""2D container item, inherited from :class:`Item`. Has a default depth of
|
||||
1.0.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payload,
|
||||
width: float,
|
||||
height: float,
|
||||
weight: float = 0.0,
|
||||
):
|
||||
super().__init__(payload, width, height, 1.0, weight)
|
||||
|
||||
def _update_bbox(self) -> None:
|
||||
v1 = Vec2(self._position)
|
||||
self._bbox = BoundingBox2d([v1, v1 + Vec2(self.get_dimension())])
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{str(self.payload)}({self.width}x{self.height}, "
|
||||
f"weight: {self.weight}) pos({str(self.position)}) "
|
||||
f"rt({self.rotation_type}) area({self.get_volume()})"
|
||||
)
|
||||
|
||||
|
||||
class Bin:
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
width: float,
|
||||
height: float,
|
||||
depth: float,
|
||||
max_weight: float = UNLIMITED_WEIGHT,
|
||||
):
|
||||
self.name = name
|
||||
self.width = float(width)
|
||||
if self.width <= 0.0:
|
||||
raise ValueError("invalid width")
|
||||
self.height = float(height)
|
||||
if self.height <= 0.0:
|
||||
raise ValueError("invalid height")
|
||||
self.depth = float(depth)
|
||||
if self.depth <= 0.0:
|
||||
raise ValueError("invalid depth")
|
||||
self.max_weight = float(max_weight)
|
||||
self.items: list[Item] = []
|
||||
|
||||
def __len__(self):
|
||||
return len(self.items)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.items)
|
||||
|
||||
def copy(self):
|
||||
"""Returns a copy."""
|
||||
box = copy.copy(self) # shallow copy
|
||||
box.items = list(self.items)
|
||||
return box
|
||||
|
||||
def reset(self):
|
||||
"""Reset the container to empty state."""
|
||||
self.items.clear()
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return not len(self.items)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"{str(self.name)}({self.width:.3f}x{self.height:.3f}x{self.depth:.3f}, "
|
||||
f"max_weight:{self.max_weight}) "
|
||||
f"vol({self.get_capacity():.3f})"
|
||||
)
|
||||
|
||||
def put_item(self, item: Item, pivot: tuple[float, float, float]) -> bool:
|
||||
valid_item_position = item.position
|
||||
item.position = pivot
|
||||
x, y, z = pivot
|
||||
|
||||
# Try all possible rotations:
|
||||
for rotation_type in self.rotations():
|
||||
item.rotation_type = rotation_type
|
||||
w, h, d = item.get_dimension()
|
||||
if self.width < x + w or self.height < y + h or self.depth < z + d:
|
||||
continue
|
||||
# new item fits inside the box at he current location and rotation:
|
||||
item_bbox = item.bbox
|
||||
if (
|
||||
not any(item_bbox.has_intersection(i.bbox) for i in self.items)
|
||||
and self.get_total_weight() + item.weight <= self.max_weight
|
||||
):
|
||||
self.items.append(item)
|
||||
return True
|
||||
|
||||
item.position = valid_item_position
|
||||
return False
|
||||
|
||||
def get_capacity(self) -> float:
|
||||
"""Returns the maximum fill volume of the bin."""
|
||||
return self.width * self.height * self.depth
|
||||
|
||||
def get_total_weight(self) -> float:
|
||||
"""Returns the total weight of all fitted items."""
|
||||
return sum(item.weight for item in self.items)
|
||||
|
||||
def get_total_volume(self) -> float:
|
||||
"""Returns the total volume of all fitted items."""
|
||||
return sum(item.get_volume() for item in self.items)
|
||||
|
||||
def get_fill_ratio(self) -> float:
|
||||
"""Return the fill ratio."""
|
||||
try:
|
||||
return self.get_total_volume() / self.get_capacity()
|
||||
except ZeroDivisionError:
|
||||
return 0.0
|
||||
|
||||
def rotations(self) -> Iterable[RotationType]:
|
||||
return RotationType
|
||||
|
||||
|
||||
class Box(Bin):
|
||||
"""3D container inherited from :class:`Bin`."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Envelope(Bin):
|
||||
"""2D container inherited from :class:`Bin`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
width: float,
|
||||
height: float,
|
||||
max_weight: float = UNLIMITED_WEIGHT,
|
||||
):
|
||||
super().__init__(name, width, height, 1.0, max_weight)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"{str(self.name)}({self.width:.3f}x{self.height:.3f}, "
|
||||
f"max_weight:{self.max_weight}) "
|
||||
f"area({self.get_capacity():.3f})"
|
||||
)
|
||||
|
||||
def rotations(self) -> Iterable[RotationType]:
|
||||
return RotationType.WHD, RotationType.HWD
|
||||
|
||||
|
||||
def _smaller_first(bins: list, items: list) -> None:
|
||||
# SMALLER_FIRST is often very bad! Especially for many in small
|
||||
# amounts increasing sizes.
|
||||
bins.sort(key=lambda b: b.get_capacity())
|
||||
items.sort(key=lambda i: i.get_volume())
|
||||
|
||||
|
||||
def _bigger_first(bins: list, items: list) -> None:
|
||||
# BIGGER_FIRST is the best strategy
|
||||
bins.sort(key=lambda b: b.get_capacity(), reverse=True)
|
||||
items.sort(key=lambda i: i.get_volume(), reverse=True)
|
||||
|
||||
|
||||
def _shuffle(bins: list, items: list) -> None:
|
||||
# Better as SMALLER_FIRST
|
||||
random.shuffle(bins)
|
||||
random.shuffle(items)
|
||||
|
||||
|
||||
PICK_STRATEGY = {
|
||||
PickStrategy.SMALLER_FIRST: _smaller_first,
|
||||
PickStrategy.BIGGER_FIRST: _bigger_first,
|
||||
PickStrategy.SHUFFLE: _shuffle,
|
||||
}
|
||||
|
||||
|
||||
class AbstractPacker:
|
||||
def __init__(self) -> None:
|
||||
self.bins: list[Bin] = []
|
||||
self.items: list[Item] = []
|
||||
self._init_state = True
|
||||
|
||||
def copy(self):
|
||||
"""Copy packer in init state to apply different pack strategies."""
|
||||
if self.is_packed:
|
||||
raise TypeError("cannot copy packed state")
|
||||
if not all(box.is_empty for box in self.bins):
|
||||
raise TypeError("bins contain data in unpacked state")
|
||||
packer = self.__class__()
|
||||
packer.bins = [box.copy() for box in self.bins]
|
||||
packer.items = [item.copy() for item in self.items]
|
||||
return packer
|
||||
|
||||
@property
|
||||
def is_packed(self) -> bool:
|
||||
"""Returns ``True`` if packer is packed, each packer can only be used
|
||||
once.
|
||||
"""
|
||||
return not self._init_state
|
||||
|
||||
@property
|
||||
def unfitted_items(self) -> list[Item]: # just an alias
|
||||
"""Returns the unfitted items."""
|
||||
return self.items
|
||||
|
||||
def __str__(self) -> str:
|
||||
fill = ""
|
||||
if self.is_packed:
|
||||
fill = f", fill ratio: {self.get_fill_ratio()}"
|
||||
return f"{self.__class__.__name__}, {len(self.bins)} bins{fill}"
|
||||
|
||||
def append_bin(self, box: Bin) -> None:
|
||||
"""Append a container."""
|
||||
if self.is_packed:
|
||||
raise TypeError("cannot append bins to packed state")
|
||||
if not box.is_empty:
|
||||
raise TypeError("cannot append bins with content")
|
||||
self.bins.append(box)
|
||||
|
||||
def append_item(self, item: Item) -> None:
|
||||
"""Append a item."""
|
||||
if self.is_packed:
|
||||
raise TypeError("cannot append items to packed state")
|
||||
self.items.append(item)
|
||||
|
||||
def get_fill_ratio(self) -> float:
|
||||
"""Return the fill ratio of all bins."""
|
||||
total_capacity = self.get_capacity()
|
||||
if total_capacity == 0.0:
|
||||
return 0.0
|
||||
return self.get_total_volume() / total_capacity
|
||||
|
||||
def get_capacity(self) -> float:
|
||||
"""Returns the maximum fill volume of all bins."""
|
||||
return sum(box.get_capacity() for box in self.bins)
|
||||
|
||||
def get_total_weight(self) -> float:
|
||||
"""Returns the total weight of all fitted items in all bins."""
|
||||
return sum(box.get_total_weight() for box in self.bins)
|
||||
|
||||
def get_total_volume(self) -> float:
|
||||
"""Returns the total volume of all fitted items in all bins."""
|
||||
return sum(box.get_total_volume() for box in self.bins)
|
||||
|
||||
def get_unfitted_volume(self) -> float:
|
||||
"""Returns the total volume of all unfitted items."""
|
||||
return sum(item.get_volume() for item in self.items)
|
||||
|
||||
def pack(self, pick=PickStrategy.BIGGER_FIRST) -> None:
|
||||
"""Pack items into bins. Distributes all items across all bins."""
|
||||
PICK_STRATEGY[pick](self.bins, self.items)
|
||||
# items are removed from self.items while packing!
|
||||
self._pack(self.bins, list(self.items))
|
||||
# unfitted items remain in self.items
|
||||
|
||||
def _pack(self, bins: Iterable[Bin], items: Iterable[Item]) -> None:
|
||||
"""Pack items into bins, removes packed items from self.items!"""
|
||||
self._init_state = False
|
||||
for box in bins:
|
||||
for item in items:
|
||||
if self.pack_to_bin(box, item):
|
||||
self.items.remove(item)
|
||||
# unfitted items remain in self.items
|
||||
|
||||
def pack_to_bin(self, box: Bin, item: Item) -> bool:
|
||||
if not box.items:
|
||||
return box.put_item(item, START_POSITION)
|
||||
|
||||
for axis in self._axis():
|
||||
for placed_item in box.items:
|
||||
w, h, d = placed_item.get_dimension()
|
||||
x, y, z = placed_item.position
|
||||
if axis == Axis.WIDTH:
|
||||
pivot = (x + w, y, z) # new item right of the placed item
|
||||
elif axis == Axis.HEIGHT:
|
||||
pivot = (x, y + h, z) # new item above of the placed item
|
||||
elif axis == Axis.DEPTH:
|
||||
pivot = (x, y, z + d) # new item on top of the placed item
|
||||
else:
|
||||
raise TypeError(axis)
|
||||
if box.put_item(item, pivot):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _axis() -> Iterable[Axis]:
|
||||
return Axis
|
||||
|
||||
|
||||
def shuffle_pack(packer: AbstractPacker, attempts: int) -> AbstractPacker:
|
||||
"""Random shuffle packing. Returns a new packer with the best packing result,
|
||||
the input packer is unchanged.
|
||||
"""
|
||||
if attempts < 1:
|
||||
raise ValueError("expected attempts >= 1")
|
||||
best_ratio = 0.0
|
||||
best_packer = packer
|
||||
for _ in range(attempts):
|
||||
new_packer = packer.copy()
|
||||
new_packer.pack(PickStrategy.SHUFFLE)
|
||||
new_ratio = new_packer.get_fill_ratio()
|
||||
if new_ratio > best_ratio:
|
||||
best_ratio = new_ratio
|
||||
best_packer = new_packer
|
||||
return best_packer
|
||||
|
||||
|
||||
def pack_item_subset(
|
||||
packer: AbstractPacker, picker: Iterable, strategy=PickStrategy.BIGGER_FIRST
|
||||
) -> None:
|
||||
"""Pack a subset of `packer.items`, which are chosen by an iterable
|
||||
yielding a True or False value for each item.
|
||||
"""
|
||||
assert packer.is_packed is False
|
||||
chosen, rejects = get_item_subset(packer.items, picker)
|
||||
packer.items = chosen
|
||||
packer.pack(strategy) # unfitted items remain in packer.items
|
||||
packer.items.extend(rejects) # append rejects as unfitted items
|
||||
|
||||
|
||||
def get_item_subset(
|
||||
items: list[Item], picker: Iterable
|
||||
) -> tuple[list[Item], list[Item]]:
|
||||
"""Returns a subset of `items`, where items are chosen by an iterable
|
||||
yielding a True or False value for each item.
|
||||
"""
|
||||
chosen: list[Item] = []
|
||||
rejects: list[Item] = []
|
||||
count = 0
|
||||
for item, pick in zip(items, picker):
|
||||
count += 1
|
||||
if pick:
|
||||
chosen.append(item)
|
||||
else:
|
||||
rejects.append(item)
|
||||
|
||||
if count < len(items): # too few pick values given
|
||||
rejects.extend(items[count:])
|
||||
return chosen, rejects
|
||||
|
||||
|
||||
class SubSetEvaluator(ga.Evaluator):
|
||||
def __init__(self, packer: AbstractPacker):
|
||||
self.packer = packer
|
||||
|
||||
def evaluate(self, dna: ga.DNA) -> float:
|
||||
packer = self.run_packer(dna)
|
||||
return packer.get_fill_ratio()
|
||||
|
||||
def run_packer(self, dna: ga.DNA) -> AbstractPacker:
|
||||
packer = self.packer.copy()
|
||||
pack_item_subset(packer, dna)
|
||||
return packer
|
||||
|
||||
|
||||
class Packer(AbstractPacker):
|
||||
"""3D Packer inherited from :class:`AbstractPacker`."""
|
||||
|
||||
def add_bin(
|
||||
self,
|
||||
name: str,
|
||||
width: float,
|
||||
height: float,
|
||||
depth: float,
|
||||
max_weight: float = UNLIMITED_WEIGHT,
|
||||
) -> Box:
|
||||
"""Add a 3D :class:`Box` container."""
|
||||
box = Box(name, width, height, depth, max_weight)
|
||||
self.append_bin(box)
|
||||
return box
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
payload,
|
||||
width: float,
|
||||
height: float,
|
||||
depth: float,
|
||||
weight: float = 0.0,
|
||||
) -> Item:
|
||||
"""Add a 3D :class:`Item` to pack."""
|
||||
item = Item(payload, width, height, depth, weight)
|
||||
self.append_item(item)
|
||||
return item
|
||||
|
||||
|
||||
class FlatPacker(AbstractPacker):
|
||||
"""2D Packer inherited from :class:`AbstractPacker`. All containers and
|
||||
items used by this packer must have a depth of 1."""
|
||||
|
||||
def add_bin(
|
||||
self,
|
||||
name: str,
|
||||
width: float,
|
||||
height: float,
|
||||
max_weight: float = UNLIMITED_WEIGHT,
|
||||
) -> Envelope:
|
||||
"""Add a 2D :class:`Envelope` container."""
|
||||
envelope = Envelope(name, width, height, max_weight)
|
||||
self.append_bin(envelope)
|
||||
return envelope
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
payload,
|
||||
width: float,
|
||||
height: float,
|
||||
weight: float = 0.0,
|
||||
) -> Item:
|
||||
"""Add a 2D :class:`FlatItem` to pack."""
|
||||
item = FlatItem(payload, width, height, weight)
|
||||
self.append_item(item)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _axis() -> Iterable[Axis]:
|
||||
return Axis.WIDTH, Axis.HEIGHT
|
||||
|
||||
|
||||
def export_dxf(
|
||||
layout: "GenericLayoutType", bins: list[Bin], offset: UVec = (1, 0, 0)
|
||||
) -> None:
|
||||
from ezdxf import colors
|
||||
|
||||
offset_vec = Vec3(offset)
|
||||
start = Vec3()
|
||||
index = 0
|
||||
rgb = (colors.RED, colors.GREEN, colors.BLUE, colors.MAGENTA, colors.CYAN)
|
||||
for box in bins:
|
||||
m = Matrix44.translate(start.x, start.y, start.z)
|
||||
_add_frame(layout, box, "FRAME", m)
|
||||
for item in box.items:
|
||||
_add_mesh(layout, item, "ITEMS", rgb[index], m)
|
||||
index += 1
|
||||
if index >= len(rgb):
|
||||
index = 0
|
||||
start += offset_vec
|
||||
|
||||
|
||||
def _add_frame(layout: "GenericLayoutType", box: Bin, layer: str, m: Matrix44):
|
||||
def add_line(v1, v2):
|
||||
line = layout.add_line(v1, v2, dxfattribs=attribs)
|
||||
line.transform(m)
|
||||
|
||||
attribs = {"layer": layer}
|
||||
x0, y0, z0 = (0.0, 0.0, 0.0)
|
||||
x1 = float(box.width)
|
||||
y1 = float(box.height)
|
||||
z1 = float(box.depth)
|
||||
corners = [
|
||||
(x0, y0),
|
||||
(x1, y0),
|
||||
(x1, y1),
|
||||
(x0, y1),
|
||||
(x0, y0),
|
||||
]
|
||||
for (sx, sy), (ex, ey) in zip(corners, corners[1:]):
|
||||
add_line((sx, sy, z0), (ex, ey, z0))
|
||||
add_line((sx, sy, z1), (ex, ey, z1))
|
||||
for x, y in corners[:-1]:
|
||||
add_line((x, y, z0), (x, y, z1))
|
||||
|
||||
text = layout.add_text(box.name, height=0.25, dxfattribs=attribs)
|
||||
text.set_placement((x0 + 0.25, y1 - 0.5, z1))
|
||||
text.transform(m)
|
||||
|
||||
|
||||
def _add_mesh(
|
||||
layout: "GenericLayoutType", item: Item, layer: str, color: int, m: Matrix44
|
||||
):
|
||||
from ezdxf.render.forms import cube
|
||||
|
||||
attribs = {
|
||||
"layer": layer,
|
||||
"color": color,
|
||||
}
|
||||
mesh = cube(center=False)
|
||||
sx, sy, sz = item.get_dimension()
|
||||
mesh.scale(sx, sy, sz)
|
||||
x, y, z = item.position
|
||||
mesh.translate(x, y, z)
|
||||
mesh.render_polyface(layout, attribs, matrix=m)
|
||||
text = layout.add_text(
|
||||
str(item.payload), height=0.25, dxfattribs={"layer": "TEXT"}
|
||||
)
|
||||
if sy > sx:
|
||||
text.dxf.rotation = 90
|
||||
align = TextEntityAlignment.TOP_LEFT
|
||||
else:
|
||||
align = TextEntityAlignment.BOTTOM_LEFT
|
||||
text.set_placement((x + 0.25, y + 0.25, z + sz), align=align)
|
||||
text.transform(m)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copyright (c) 2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from .data import *
|
||||
from .model import *
|
||||
from .browser import *
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
|
||||
class Bookmark(NamedTuple):
|
||||
name: str
|
||||
handle: str
|
||||
offset: int
|
||||
|
||||
|
||||
class Bookmarks:
|
||||
def __init__(self) -> None:
|
||||
self.bookmarks: dict[str, Bookmark] = dict()
|
||||
|
||||
def add(self, name: str, handle: str, offset: int):
|
||||
self.bookmarks[name] = Bookmark(name, handle, offset)
|
||||
|
||||
def get(self, name: str) -> Optional[Bookmark]:
|
||||
return self.bookmarks.get(name)
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self.bookmarks.keys())
|
||||
|
||||
def discard(self, name: str):
|
||||
try:
|
||||
del self.bookmarks[name]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def clear(self):
|
||||
self.bookmarks.clear()
|
||||
@@ -0,0 +1,796 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Set
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import shlex
|
||||
|
||||
from ezdxf.addons.xqt import (
|
||||
QtWidgets,
|
||||
QtGui,
|
||||
QAction,
|
||||
QMessageBox,
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
Qt,
|
||||
QModelIndex,
|
||||
QSettings,
|
||||
QFileSystemWatcher,
|
||||
QSize,
|
||||
)
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.lldxf.const import DXFStructureError, DXFValueError
|
||||
from ezdxf.lldxf.types import DXFTag, is_pointer_code
|
||||
from ezdxf.lldxf.tags import Tags
|
||||
from ezdxf.addons.browser.reflinks import get_reference_link
|
||||
|
||||
from .model import (
|
||||
DXFStructureModel,
|
||||
DXFTagsModel,
|
||||
DXFTagsRole,
|
||||
)
|
||||
from .data import (
|
||||
DXFDocument,
|
||||
get_row_from_line_number,
|
||||
dxfstr,
|
||||
EntityHistory,
|
||||
SearchIndex,
|
||||
)
|
||||
from .views import StructureTree, DXFTagsTable
|
||||
from .find_dialog import Ui_FindDialog
|
||||
from .bookmarks import Bookmarks
|
||||
|
||||
__all__ = ["DXFStructureBrowser"]
|
||||
|
||||
APP_NAME = "DXF Structure Browser"
|
||||
BROWSE_COMMAND = ezdxf.options.BROWSE_COMMAND
|
||||
TEXT_EDITOR = ezdxf.options.get(BROWSE_COMMAND, "TEXT_EDITOR")
|
||||
ICON_SIZE = max(16, ezdxf.options.get_int(BROWSE_COMMAND, "ICON_SIZE"))
|
||||
|
||||
SearchSections = Set[str]
|
||||
|
||||
|
||||
def searchable_entities(
|
||||
doc: DXFDocument, search_sections: SearchSections
|
||||
) -> list[Tags]:
|
||||
entities: list[Tags] = []
|
||||
for name, section_entities in doc.sections.items():
|
||||
if name in search_sections:
|
||||
entities.extend(section_entities) # type: ignore
|
||||
return entities
|
||||
|
||||
|
||||
BROWSER_WIDTH = 1024
|
||||
BROWSER_HEIGHT = 768
|
||||
TREE_WIDTH_FACTOR = 0.33
|
||||
|
||||
|
||||
class DXFStructureBrowser(QtWidgets.QMainWindow):
|
||||
def __init__(
|
||||
self,
|
||||
filename: str = "",
|
||||
line: Optional[int] = None,
|
||||
handle: Optional[str] = None,
|
||||
resource_path: Path = Path("."),
|
||||
):
|
||||
super().__init__()
|
||||
self.doc = DXFDocument()
|
||||
self.resource_path = resource_path
|
||||
self._structure_tree = StructureTree()
|
||||
self._dxf_tags_table = DXFTagsTable()
|
||||
self._current_entity: Optional[Tags] = None
|
||||
self._active_search: Optional[SearchIndex] = None
|
||||
self._search_sections: set[str] = set()
|
||||
self._find_dialog: FindDialog = self.create_find_dialog()
|
||||
self._file_watcher = QFileSystemWatcher()
|
||||
self._exclusive_reload_dialog = True # see ask_for_reloading() method
|
||||
self.history = EntityHistory()
|
||||
self.bookmarks = Bookmarks()
|
||||
self.setup_actions()
|
||||
self.setup_menu()
|
||||
self.setup_toolbar()
|
||||
|
||||
if filename:
|
||||
self.load_dxf(filename)
|
||||
else:
|
||||
self.setWindowTitle(APP_NAME)
|
||||
|
||||
self.setCentralWidget(self.build_central_widget())
|
||||
self.resize(BROWSER_WIDTH, BROWSER_HEIGHT)
|
||||
self.connect_slots()
|
||||
if line is not None:
|
||||
try:
|
||||
line = int(line)
|
||||
except ValueError:
|
||||
print(f"Invalid line number: {line}")
|
||||
else:
|
||||
self.goto_line(line)
|
||||
if handle is not None:
|
||||
try:
|
||||
int(handle, 16)
|
||||
except ValueError:
|
||||
print(f"Given handle is not a hex value: {handle}")
|
||||
else:
|
||||
if not self.goto_handle(handle):
|
||||
print(f"Handle {handle} not found.")
|
||||
|
||||
def build_central_widget(self):
|
||||
container = QtWidgets.QSplitter(Qt.Horizontal)
|
||||
container.addWidget(self._structure_tree)
|
||||
container.addWidget(self._dxf_tags_table)
|
||||
tree_width = int(BROWSER_WIDTH * TREE_WIDTH_FACTOR)
|
||||
table_width = BROWSER_WIDTH - tree_width
|
||||
container.setSizes([tree_width, table_width])
|
||||
container.setCollapsible(0, False)
|
||||
container.setCollapsible(1, False)
|
||||
return container
|
||||
|
||||
def connect_slots(self):
|
||||
self._structure_tree.activated.connect(self.entity_activated)
|
||||
self._dxf_tags_table.activated.connect(self.tag_activated)
|
||||
# noinspection PyUnresolvedReferences
|
||||
self._file_watcher.fileChanged.connect(self.ask_for_reloading)
|
||||
|
||||
# noinspection PyAttributeOutsideInit
|
||||
def setup_actions(self):
|
||||
|
||||
self._open_action = self.make_action(
|
||||
"&Open DXF File...", self.open_dxf, shortcut="Ctrl+O"
|
||||
)
|
||||
self._export_entity_action = self.make_action(
|
||||
"&Export DXF Entity...", self.export_entity, shortcut="Ctrl+E"
|
||||
)
|
||||
self._copy_entity_action = self.make_action(
|
||||
"&Copy DXF Entity to Clipboard",
|
||||
self.copy_entity,
|
||||
shortcut="Shift+Ctrl+C",
|
||||
icon_name="icon-copy-64px.png",
|
||||
)
|
||||
self._copy_selected_tags_action = self.make_action(
|
||||
"&Copy selected DXF Tags to Clipboard",
|
||||
self.copy_selected_tags,
|
||||
shortcut="Ctrl+C",
|
||||
icon_name="icon-copy-64px.png",
|
||||
)
|
||||
self._quit_action = self.make_action(
|
||||
"&Quit", self.close, shortcut="Ctrl+Q"
|
||||
)
|
||||
self._goto_handle_action = self.make_action(
|
||||
"&Go to Handle...",
|
||||
self.ask_for_handle,
|
||||
shortcut="Ctrl+G",
|
||||
icon_name="icon-goto-handle-64px.png",
|
||||
tip="Go to Entity Handle",
|
||||
)
|
||||
self._goto_line_action = self.make_action(
|
||||
"Go to &Line...",
|
||||
self.ask_for_line_number,
|
||||
shortcut="Ctrl+L",
|
||||
icon_name="icon-goto-line-64px.png",
|
||||
tip="Go to Line Number",
|
||||
)
|
||||
|
||||
self._find_text_action = self.make_action(
|
||||
"Find &Text...",
|
||||
self.find_text,
|
||||
shortcut="Ctrl+F",
|
||||
icon_name="icon-find-64px.png",
|
||||
tip="Find Text in Entities",
|
||||
)
|
||||
self._goto_predecessor_entity_action = self.make_action(
|
||||
"&Previous Entity",
|
||||
self.goto_previous_entity,
|
||||
shortcut="Ctrl+Left",
|
||||
icon_name="icon-prev-entity-64px.png",
|
||||
tip="Go to Previous Entity in File Order",
|
||||
)
|
||||
|
||||
self._goto_next_entity_action = self.make_action(
|
||||
"&Next Entity",
|
||||
self.goto_next_entity,
|
||||
shortcut="Ctrl+Right",
|
||||
icon_name="icon-next-entity-64px.png",
|
||||
tip="Go to Next Entity in File Order",
|
||||
)
|
||||
self._entity_history_back_action = self.make_action(
|
||||
"Entity History &Back",
|
||||
self.go_back_entity_history,
|
||||
shortcut="Alt+Left",
|
||||
icon_name="icon-left-arrow-64px.png",
|
||||
tip="Go to Previous Entity in Browser History",
|
||||
)
|
||||
self._entity_history_forward_action = self.make_action(
|
||||
"Entity History &Forward",
|
||||
self.go_forward_entity_history,
|
||||
shortcut="Alt+Right",
|
||||
icon_name="icon-right-arrow-64px.png",
|
||||
tip="Go to Next Entity in Browser History",
|
||||
)
|
||||
self._open_entity_in_text_editor_action = self.make_action(
|
||||
"&Open in Text Editor",
|
||||
self.open_entity_in_text_editor,
|
||||
shortcut="Ctrl+T",
|
||||
)
|
||||
self._show_entity_in_tree_view_action = self.make_action(
|
||||
"Show Entity in Structure &Tree",
|
||||
self.show_current_entity_in_tree_view,
|
||||
shortcut="Ctrl+Down",
|
||||
icon_name="icon-show-in-tree-64px.png",
|
||||
tip="Show Current Entity in Structure Tree",
|
||||
)
|
||||
self._goto_header_action = self.make_action(
|
||||
"Go to HEADER Section",
|
||||
partial(self.go_to_section, name="HEADER"),
|
||||
shortcut="Shift+H",
|
||||
)
|
||||
self._goto_blocks_action = self.make_action(
|
||||
"Go to BLOCKS Section",
|
||||
partial(self.go_to_section, name="BLOCKS"),
|
||||
shortcut="Shift+B",
|
||||
)
|
||||
self._goto_entities_action = self.make_action(
|
||||
"Go to ENTITIES Section",
|
||||
partial(self.go_to_section, name="ENTITIES"),
|
||||
shortcut="Shift+E",
|
||||
)
|
||||
self._goto_objects_action = self.make_action(
|
||||
"Go to OBJECTS Section",
|
||||
partial(self.go_to_section, name="OBJECTS"),
|
||||
shortcut="Shift+O",
|
||||
)
|
||||
self._store_bookmark = self.make_action(
|
||||
"Store Bookmark...",
|
||||
self.store_bookmark,
|
||||
shortcut="Shift+Ctrl+B",
|
||||
icon_name="icon-store-bookmark-64px.png",
|
||||
)
|
||||
self._go_to_bookmark = self.make_action(
|
||||
"Go to Bookmark...",
|
||||
self.go_to_bookmark,
|
||||
shortcut="Ctrl+B",
|
||||
icon_name="icon-goto-bookmark-64px.png",
|
||||
)
|
||||
self._reload_action = self.make_action(
|
||||
"Reload DXF File",
|
||||
self.reload_dxf,
|
||||
shortcut="Ctrl+R",
|
||||
)
|
||||
|
||||
def make_action(
|
||||
self,
|
||||
name,
|
||||
slot,
|
||||
*,
|
||||
shortcut: str = "",
|
||||
icon_name: str = "",
|
||||
tip: str = "",
|
||||
) -> QAction:
|
||||
action = QAction(name, self)
|
||||
if shortcut:
|
||||
action.setShortcut(shortcut)
|
||||
if icon_name:
|
||||
icon = QtGui.QIcon(str(self.resource_path / icon_name))
|
||||
action.setIcon(icon)
|
||||
if tip:
|
||||
action.setToolTip(tip)
|
||||
action.triggered.connect(slot)
|
||||
return action
|
||||
|
||||
def setup_menu(self):
|
||||
menu = self.menuBar()
|
||||
file_menu = menu.addMenu("&File")
|
||||
file_menu.addAction(self._open_action)
|
||||
file_menu.addAction(self._reload_action)
|
||||
file_menu.addAction(self._open_entity_in_text_editor_action)
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(self._copy_selected_tags_action)
|
||||
file_menu.addAction(self._copy_entity_action)
|
||||
file_menu.addAction(self._export_entity_action)
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(self._quit_action)
|
||||
|
||||
navigate_menu = menu.addMenu("&Navigate")
|
||||
navigate_menu.addAction(self._goto_handle_action)
|
||||
navigate_menu.addAction(self._goto_line_action)
|
||||
navigate_menu.addAction(self._find_text_action)
|
||||
navigate_menu.addSeparator()
|
||||
navigate_menu.addAction(self._goto_next_entity_action)
|
||||
navigate_menu.addAction(self._goto_predecessor_entity_action)
|
||||
navigate_menu.addAction(self._show_entity_in_tree_view_action)
|
||||
navigate_menu.addSeparator()
|
||||
navigate_menu.addAction(self._entity_history_back_action)
|
||||
navigate_menu.addAction(self._entity_history_forward_action)
|
||||
navigate_menu.addSeparator()
|
||||
navigate_menu.addAction(self._goto_header_action)
|
||||
navigate_menu.addAction(self._goto_blocks_action)
|
||||
navigate_menu.addAction(self._goto_entities_action)
|
||||
navigate_menu.addAction(self._goto_objects_action)
|
||||
|
||||
bookmarks_menu = menu.addMenu("&Bookmarks")
|
||||
bookmarks_menu.addAction(self._store_bookmark)
|
||||
bookmarks_menu.addAction(self._go_to_bookmark)
|
||||
|
||||
def setup_toolbar(self) -> None:
|
||||
toolbar = QtWidgets.QToolBar("MainToolbar")
|
||||
toolbar.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
|
||||
toolbar.addAction(self._entity_history_back_action)
|
||||
toolbar.addAction(self._entity_history_forward_action)
|
||||
toolbar.addAction(self._goto_predecessor_entity_action)
|
||||
toolbar.addAction(self._goto_next_entity_action)
|
||||
toolbar.addAction(self._show_entity_in_tree_view_action)
|
||||
toolbar.addAction(self._find_text_action)
|
||||
toolbar.addAction(self._goto_line_action)
|
||||
toolbar.addAction(self._goto_handle_action)
|
||||
toolbar.addAction(self._store_bookmark)
|
||||
toolbar.addAction(self._go_to_bookmark)
|
||||
toolbar.addAction(self._copy_selected_tags_action)
|
||||
self.addToolBar(toolbar)
|
||||
|
||||
def create_find_dialog(self) -> "FindDialog":
|
||||
dialog = FindDialog()
|
||||
dialog.setModal(True)
|
||||
dialog.find_forward_button.clicked.connect(self.find_forward)
|
||||
dialog.find_backwards_button.clicked.connect(self.find_backwards)
|
||||
dialog.find_forward_button.setShortcut("F3")
|
||||
dialog.find_backwards_button.setShortcut("F4")
|
||||
return dialog
|
||||
|
||||
def open_dxf(self):
|
||||
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||
self,
|
||||
caption="Select DXF file",
|
||||
filter="DXF Documents (*.dxf *.DXF)",
|
||||
)
|
||||
if path:
|
||||
self.load_dxf(path)
|
||||
|
||||
def load_dxf(self, path: str):
|
||||
try:
|
||||
self._load(path)
|
||||
except IOError as e:
|
||||
QMessageBox.critical(self, "Loading Error", str(e))
|
||||
except DXFStructureError as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"DXF Structure Error",
|
||||
f'Invalid DXF file "{path}": {str(e)}',
|
||||
)
|
||||
else:
|
||||
self.history.clear()
|
||||
self.view_header_section()
|
||||
self.update_title()
|
||||
|
||||
def reload_dxf(self):
|
||||
if self._current_entity is not None:
|
||||
entity = self.get_current_entity()
|
||||
handle = self.get_current_entity_handle()
|
||||
first_row = self._dxf_tags_table.first_selected_row()
|
||||
line_number = self.doc.get_line_number(entity, first_row)
|
||||
|
||||
self._load(self.doc.filename)
|
||||
if handle is not None:
|
||||
entity = self.doc.get_entity(handle)
|
||||
if entity is not None: # select entity with same handle
|
||||
self.set_current_entity_and_row_index(entity, first_row)
|
||||
self._structure_tree.expand_to_entity(entity)
|
||||
return
|
||||
# select entity at the same line number
|
||||
entity = self.doc.get_entity_at_line(line_number)
|
||||
self.set_current_entity_and_row_index(entity, first_row)
|
||||
self._structure_tree.expand_to_entity(entity)
|
||||
|
||||
def ask_for_reloading(self):
|
||||
if self.doc.filename and self._exclusive_reload_dialog:
|
||||
# Ignore further reload signals until first signal is processed.
|
||||
# Saving files by ezdxf triggers two "fileChanged" signals!?
|
||||
self._exclusive_reload_dialog = False
|
||||
ok = QMessageBox.question(
|
||||
self,
|
||||
"Reload",
|
||||
f'"{self.doc.absolute_filepath()}"\n\nThis file has been '
|
||||
f"modified by another program, reload file?",
|
||||
buttons=QMessageBox.Yes | QMessageBox.No,
|
||||
defaultButton=QMessageBox.Yes,
|
||||
)
|
||||
if ok == QMessageBox.Yes:
|
||||
self.reload_dxf()
|
||||
self._exclusive_reload_dialog = True
|
||||
|
||||
def _load(self, filename: str):
|
||||
if self.doc.filename:
|
||||
self._file_watcher.removePath(self.doc.filename)
|
||||
self.doc.load(filename)
|
||||
model = DXFStructureModel(self.doc.filepath.name, self.doc)
|
||||
self._structure_tree.set_structure(model)
|
||||
self.history.clear()
|
||||
self._file_watcher.addPath(self.doc.filename)
|
||||
|
||||
def export_entity(self):
|
||||
if self._dxf_tags_table is None:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
caption="Export DXF Entity",
|
||||
filter="Text Files (*.txt *.TXT)",
|
||||
)
|
||||
if path:
|
||||
model = self._dxf_tags_table.model()
|
||||
tags = model.compiled_tags()
|
||||
self.export_tags(path, tags)
|
||||
|
||||
def copy_entity(self):
|
||||
if self._dxf_tags_table is None:
|
||||
return
|
||||
model = self._dxf_tags_table.model()
|
||||
tags = model.compiled_tags()
|
||||
copy_dxf_to_clipboard(tags)
|
||||
|
||||
def copy_selected_tags(self):
|
||||
if self._current_entity is None:
|
||||
return
|
||||
rows = self._dxf_tags_table.selected_rows()
|
||||
model = self._dxf_tags_table.model()
|
||||
tags = model.compiled_tags()
|
||||
try:
|
||||
export_tags = Tags(tags[row] for row in rows)
|
||||
except IndexError:
|
||||
return
|
||||
copy_dxf_to_clipboard(export_tags)
|
||||
|
||||
def view_header_section(self):
|
||||
header = self.doc.get_section("HEADER")
|
||||
if header:
|
||||
self.set_current_entity_with_history(header[0])
|
||||
else: # DXF R12 with only a ENTITIES section
|
||||
entities = self.doc.get_section("ENTITIES")
|
||||
if entities:
|
||||
self.set_current_entity_with_history(entities[1])
|
||||
|
||||
def update_title(self):
|
||||
self.setWindowTitle(f"{APP_NAME} - {self.doc.absolute_filepath()}")
|
||||
|
||||
def get_current_entity_handle(self) -> Optional[str]:
|
||||
active_entity = self.get_current_entity()
|
||||
if active_entity:
|
||||
try:
|
||||
return active_entity.get_handle()
|
||||
except DXFValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_current_entity(self) -> Optional[Tags]:
|
||||
return self._current_entity
|
||||
|
||||
def set_current_entity_by_handle(self, handle: str):
|
||||
entity = self.doc.get_entity(handle)
|
||||
if entity:
|
||||
self.set_current_entity(entity)
|
||||
|
||||
def set_current_entity(
|
||||
self, entity: Tags, select_line_number: Optional[int] = None
|
||||
):
|
||||
if entity:
|
||||
self._current_entity = entity
|
||||
start_line_number = self.doc.get_line_number(entity)
|
||||
model = DXFTagsModel(
|
||||
entity, start_line_number, self.doc.entity_index
|
||||
)
|
||||
self._dxf_tags_table.setModel(model)
|
||||
if select_line_number is not None:
|
||||
row = get_row_from_line_number(
|
||||
model.compiled_tags(), start_line_number, select_line_number
|
||||
)
|
||||
self._dxf_tags_table.selectRow(row)
|
||||
index = self._dxf_tags_table.model().index(row, 0)
|
||||
self._dxf_tags_table.scrollTo(index)
|
||||
|
||||
def set_current_entity_with_history(self, entity: Tags):
|
||||
self.set_current_entity(entity)
|
||||
self.history.append(entity)
|
||||
|
||||
def set_current_entity_and_row_index(self, entity: Tags, index: int):
|
||||
line = self.doc.get_line_number(entity, index)
|
||||
self.set_current_entity(entity, select_line_number=line)
|
||||
self.history.append(entity)
|
||||
|
||||
def entity_activated(self, index: QModelIndex):
|
||||
tags = index.data(role=DXFTagsRole)
|
||||
# PySide6: Tags() are converted to type list by PySide6?
|
||||
# print(type(tags))
|
||||
if isinstance(tags, (Tags, list)):
|
||||
self.set_current_entity_with_history(Tags(tags))
|
||||
|
||||
def tag_activated(self, index: QModelIndex):
|
||||
tag = index.data(role=DXFTagsRole)
|
||||
if isinstance(tag, DXFTag):
|
||||
code, value = tag
|
||||
if is_pointer_code(code):
|
||||
if not self.goto_handle(value):
|
||||
self.show_error_handle_not_found(value)
|
||||
elif code == 0:
|
||||
self.open_web_browser(get_reference_link(value))
|
||||
|
||||
def ask_for_handle(self):
|
||||
handle, ok = QInputDialog.getText(
|
||||
self,
|
||||
"Go to",
|
||||
"Go to entity handle:",
|
||||
)
|
||||
if ok:
|
||||
if not self.goto_handle(handle):
|
||||
self.show_error_handle_not_found(handle)
|
||||
|
||||
def goto_handle(self, handle: str) -> bool:
|
||||
entity = self.doc.get_entity(handle)
|
||||
if entity:
|
||||
self.set_current_entity_with_history(entity)
|
||||
return True
|
||||
return False
|
||||
|
||||
def show_error_handle_not_found(self, handle: str):
|
||||
QMessageBox.critical(self, "Error", f"Handle {handle} not found!")
|
||||
|
||||
def ask_for_line_number(self):
|
||||
max_line_number = self.doc.max_line_number
|
||||
number, ok = QInputDialog.getInt(
|
||||
self,
|
||||
"Go to",
|
||||
f"Go to line number: (max. {max_line_number})",
|
||||
1, # value
|
||||
1, # PyQt5: min, PySide6: minValue
|
||||
max_line_number, # PyQt5: max, PySide6: maxValue
|
||||
)
|
||||
if ok:
|
||||
self.goto_line(number)
|
||||
|
||||
def goto_line(self, number: int) -> bool:
|
||||
entity = self.doc.get_entity_at_line(int(number))
|
||||
if entity:
|
||||
self.set_current_entity(entity, number)
|
||||
return True
|
||||
return False
|
||||
|
||||
def find_text(self):
|
||||
self._active_search = None
|
||||
dialog = self._find_dialog
|
||||
dialog.restore_geometry()
|
||||
dialog.show_message("F3 searches forward, F4 searches backwards")
|
||||
dialog.find_text_edit.setFocus()
|
||||
dialog.show()
|
||||
|
||||
def update_search(self):
|
||||
def setup_search():
|
||||
self._search_sections = dialog.search_sections()
|
||||
entities = searchable_entities(self.doc, self._search_sections)
|
||||
self._active_search = SearchIndex(entities)
|
||||
|
||||
dialog = self._find_dialog
|
||||
if self._active_search is None:
|
||||
setup_search()
|
||||
# noinspection PyUnresolvedReferences
|
||||
self._active_search.set_current_entity(self._current_entity)
|
||||
else:
|
||||
search_sections = dialog.search_sections()
|
||||
if search_sections != self._search_sections:
|
||||
setup_search()
|
||||
dialog.update_options(self._active_search)
|
||||
|
||||
def find_forward(self):
|
||||
self._find(backward=False)
|
||||
|
||||
def find_backwards(self):
|
||||
self._find(backward=True)
|
||||
|
||||
def _find(self, backward=False):
|
||||
if self._find_dialog.isVisible():
|
||||
self.update_search()
|
||||
search = self._active_search
|
||||
if search.is_end_of_index:
|
||||
search.reset_cursor(backward=backward)
|
||||
|
||||
entity, index = (
|
||||
search.find_backwards() if backward else search.find_forward()
|
||||
)
|
||||
|
||||
if entity:
|
||||
self.set_current_entity_and_row_index(entity, index)
|
||||
self.show_entity_found_message(entity, index)
|
||||
else:
|
||||
if search.is_end_of_index:
|
||||
self.show_message("Not found and end of file!")
|
||||
else:
|
||||
self.show_message("Not found!")
|
||||
|
||||
def show_message(self, msg: str):
|
||||
self._find_dialog.show_message(msg)
|
||||
|
||||
def show_entity_found_message(self, entity: Tags, index: int):
|
||||
dxftype = entity.dxftype()
|
||||
if dxftype == "SECTION":
|
||||
tail = " @ {0} Section".format(entity.get_first_value(2))
|
||||
else:
|
||||
try:
|
||||
handle = entity.get_handle()
|
||||
tail = f" @ {dxftype}(#{handle})"
|
||||
except ValueError:
|
||||
tail = ""
|
||||
line = self.doc.get_line_number(entity, index)
|
||||
self.show_message(f"Found in Line: {line}{tail}")
|
||||
|
||||
def export_tags(self, filename: str, tags: Tags):
|
||||
try:
|
||||
with open(filename, "wt", encoding="utf8") as fp:
|
||||
fp.write(dxfstr(tags))
|
||||
except IOError as e:
|
||||
QMessageBox.critical(self, "IOError", str(e))
|
||||
|
||||
def goto_next_entity(self):
|
||||
if self._dxf_tags_table:
|
||||
current_entity = self.get_current_entity()
|
||||
if current_entity is not None:
|
||||
next_entity = self.doc.next_entity(current_entity)
|
||||
if next_entity is not None:
|
||||
self.set_current_entity_with_history(next_entity)
|
||||
|
||||
def goto_previous_entity(self):
|
||||
if self._dxf_tags_table:
|
||||
current_entity = self.get_current_entity()
|
||||
if current_entity is not None:
|
||||
prev_entity = self.doc.previous_entity(current_entity)
|
||||
if prev_entity is not None:
|
||||
self.set_current_entity_with_history(prev_entity)
|
||||
|
||||
def go_back_entity_history(self):
|
||||
entity = self.history.back()
|
||||
if entity is not None:
|
||||
self.set_current_entity(entity) # do not change history
|
||||
|
||||
def go_forward_entity_history(self):
|
||||
entity = self.history.forward()
|
||||
if entity is not None:
|
||||
self.set_current_entity(entity) # do not change history
|
||||
|
||||
def go_to_section(self, name: str):
|
||||
section = self.doc.get_section(name)
|
||||
if section:
|
||||
index = 0 if name == "HEADER" else 1
|
||||
self.set_current_entity_with_history(section[index])
|
||||
|
||||
def open_entity_in_text_editor(self):
|
||||
current_entity = self.get_current_entity()
|
||||
line_number = self.doc.get_line_number(current_entity)
|
||||
if self._dxf_tags_table:
|
||||
indices = self._dxf_tags_table.selectedIndexes()
|
||||
if indices:
|
||||
model = self._dxf_tags_table.model()
|
||||
row = indices[0].row()
|
||||
line_number = model.line_number(row)
|
||||
self._open_text_editor(
|
||||
str(self.doc.absolute_filepath()), line_number
|
||||
)
|
||||
|
||||
def _open_text_editor(self, filename: str, line_number: int) -> None:
|
||||
cmd = TEXT_EDITOR.format(
|
||||
filename=filename,
|
||||
num=line_number,
|
||||
)
|
||||
args = shlex.split(cmd)
|
||||
try:
|
||||
subprocess.Popen(args)
|
||||
except FileNotFoundError:
|
||||
QMessageBox.critical(
|
||||
self, "Text Editor", "Error calling text editor:\n" + cmd
|
||||
)
|
||||
|
||||
def open_web_browser(self, url: str):
|
||||
import webbrowser
|
||||
|
||||
webbrowser.open(url)
|
||||
|
||||
def show_current_entity_in_tree_view(self):
|
||||
entity = self.get_current_entity()
|
||||
if entity:
|
||||
self._structure_tree.expand_to_entity(entity)
|
||||
|
||||
def store_bookmark(self):
|
||||
if self._current_entity is not None:
|
||||
bookmarks = self.bookmarks.names()
|
||||
if len(bookmarks) == 0:
|
||||
bookmarks = ["0"]
|
||||
name, ok = QInputDialog.getItem(
|
||||
self,
|
||||
"Store Bookmark",
|
||||
"Bookmark:",
|
||||
bookmarks,
|
||||
editable=True,
|
||||
)
|
||||
if ok:
|
||||
entity = self._current_entity
|
||||
rows = self._dxf_tags_table.selectedIndexes()
|
||||
if rows:
|
||||
offset = rows[0].row()
|
||||
else:
|
||||
offset = 0
|
||||
handle = self.doc.get_handle(entity)
|
||||
self.bookmarks.add(name, handle, offset)
|
||||
|
||||
def go_to_bookmark(self):
|
||||
bookmarks = self.bookmarks.names()
|
||||
if len(bookmarks) == 0:
|
||||
QMessageBox.information(self, "Info", "No Bookmarks defined!")
|
||||
return
|
||||
|
||||
name, ok = QInputDialog.getItem(
|
||||
self,
|
||||
"Go to Bookmark",
|
||||
"Bookmark:",
|
||||
self.bookmarks.names(),
|
||||
editable=False,
|
||||
)
|
||||
if ok:
|
||||
bookmark = self.bookmarks.get(name)
|
||||
if bookmark is not None:
|
||||
self.set_current_entity_by_handle(bookmark.handle)
|
||||
self._dxf_tags_table.selectRow(bookmark.offset)
|
||||
model = self._dxf_tags_table.model()
|
||||
index = QModelIndex(model.index(bookmark.offset, 0))
|
||||
self._dxf_tags_table.scrollTo(index)
|
||||
|
||||
else:
|
||||
QtWidgets.QMessageBox.critical(
|
||||
self, "Bookmark not found!", str(name)
|
||||
)
|
||||
|
||||
|
||||
def copy_dxf_to_clipboard(tags: Tags):
|
||||
clipboard = QtWidgets.QApplication.clipboard()
|
||||
try:
|
||||
mode = clipboard.Mode.Clipboard
|
||||
except AttributeError:
|
||||
mode = clipboard.Clipboard # type: ignore # legacy location
|
||||
clipboard.setText(dxfstr(tags), mode=mode)
|
||||
|
||||
|
||||
class FindDialog(QtWidgets.QDialog, Ui_FindDialog):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setupUi(self)
|
||||
self.close_button.clicked.connect(lambda: self.close())
|
||||
self.settings = QSettings("ezdxf", "DXFBrowser")
|
||||
|
||||
def restore_geometry(self):
|
||||
geometry = self.settings.value("find.dialog.geometry")
|
||||
if geometry is not None:
|
||||
self.restoreGeometry(geometry)
|
||||
|
||||
def search_sections(self) -> SearchSections:
|
||||
sections = set()
|
||||
if self.header_check_box.isChecked():
|
||||
sections.add("HEADER")
|
||||
if self.classes_check_box.isChecked():
|
||||
sections.add("CLASSES")
|
||||
if self.tables_check_box.isChecked():
|
||||
sections.add("TABLES")
|
||||
if self.blocks_check_box.isChecked():
|
||||
sections.add("BLOCKS")
|
||||
if self.entities_check_box.isChecked():
|
||||
sections.add("ENTITIES")
|
||||
if self.objects_check_box.isChecked():
|
||||
sections.add("OBJECTS")
|
||||
return sections
|
||||
|
||||
def update_options(self, search: SearchIndex) -> None:
|
||||
search.reset_search_term(self.find_text_edit.text())
|
||||
search.case_insensitive = not self.match_case_check_box.isChecked()
|
||||
search.whole_words = self.whole_words_check_box.isChecked()
|
||||
search.numbers = self.number_tags_check_box.isChecked()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.settings.setValue("find.dialog.geometry", self.saveGeometry())
|
||||
super().closeEvent(event)
|
||||
|
||||
def show_message(self, msg: str):
|
||||
self.message.setText(msg)
|
||||
@@ -0,0 +1,428 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Iterable, Any, TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
from ezdxf.addons.browser.loader import load_section_dict
|
||||
from ezdxf.lldxf.types import DXFVertex, tag_type
|
||||
from ezdxf.lldxf.tags import Tags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.eztypes import SectionDict
|
||||
|
||||
__all__ = [
|
||||
"DXFDocument",
|
||||
"IndexEntry",
|
||||
"get_row_from_line_number",
|
||||
"dxfstr",
|
||||
"EntityHistory",
|
||||
"SearchIndex",
|
||||
]
|
||||
|
||||
|
||||
class DXFDocument:
|
||||
def __init__(self, sections: Optional[SectionDict] = None):
|
||||
# Important: the section dict has to store the raw string tags
|
||||
# else an association of line numbers to entities is not possible.
|
||||
# Comment tags (999) are ignored, because the load_section_dict()
|
||||
# function can not handle and store comments.
|
||||
# Therefore comments causes incorrect results for the line number
|
||||
# associations and should be stripped off before processing for precise
|
||||
# debugging of DXF files (-b for backup):
|
||||
# ezdxf strip -b <your.dxf>
|
||||
self.sections: SectionDict = dict()
|
||||
self.entity_index: Optional[EntityIndex] = None
|
||||
self.valid_handles = None
|
||||
self.filename = ""
|
||||
if sections:
|
||||
self.update(sections)
|
||||
|
||||
@property
|
||||
def filepath(self):
|
||||
return Path(self.filename)
|
||||
|
||||
@property
|
||||
def max_line_number(self) -> int:
|
||||
if self.entity_index:
|
||||
return self.entity_index.max_line_number
|
||||
else:
|
||||
return 1
|
||||
|
||||
def load(self, filename: str):
|
||||
self.filename = filename
|
||||
self.update(load_section_dict(filename))
|
||||
|
||||
def update(self, sections: SectionDict):
|
||||
self.sections = sections
|
||||
self.entity_index = EntityIndex(self.sections)
|
||||
|
||||
def absolute_filepath(self):
|
||||
return self.filepath.absolute()
|
||||
|
||||
def get_section(self, name: str) -> list[Tags]:
|
||||
return self.sections.get(name) # type: ignore
|
||||
|
||||
def get_entity(self, handle: str) -> Optional[Tags]:
|
||||
if self.entity_index:
|
||||
return self.entity_index.get(handle)
|
||||
return None
|
||||
|
||||
def get_line_number(self, entity: Tags, offset: int = 0) -> int:
|
||||
if self.entity_index:
|
||||
return (
|
||||
self.entity_index.get_start_line_for_entity(entity) + offset * 2
|
||||
)
|
||||
return 0
|
||||
|
||||
def get_entity_at_line(self, number: int) -> Optional[Tags]:
|
||||
if self.entity_index:
|
||||
return self.entity_index.get_entity_at_line(number)
|
||||
return None
|
||||
|
||||
def next_entity(self, entity: Tags) -> Optional[Tags]:
|
||||
return self.entity_index.next_entity(entity) # type: ignore
|
||||
|
||||
def previous_entity(self, entity: Tags) -> Optional[Tags]:
|
||||
return self.entity_index.previous_entity(entity) # type: ignore
|
||||
|
||||
def get_handle(self, entity) -> Optional[str]:
|
||||
return self.entity_index.get_handle(entity) # type: ignore
|
||||
|
||||
|
||||
class IndexEntry:
|
||||
def __init__(self, tags: Tags, line: int = 0):
|
||||
self.tags: Tags = tags
|
||||
self.start_line_number: int = line
|
||||
self.prev: Optional["IndexEntry"] = None
|
||||
self.next: Optional["IndexEntry"] = None
|
||||
|
||||
|
||||
class EntityIndex:
|
||||
def __init__(self, sections: SectionDict):
|
||||
# dict() entries have to be ordered since Python 3.6!
|
||||
# Therefore _index.values() returns the DXF entities in file order!
|
||||
self._index: dict[str, IndexEntry] = dict()
|
||||
# Index dummy handle of entities without handles by the id of the
|
||||
# first tag for faster retrieval of the dummy handle from tags:
|
||||
# dict items: (id, handle)
|
||||
self._dummy_handle_index: dict[int, str] = dict()
|
||||
self._max_line_number: int = 0
|
||||
self._build(sections)
|
||||
|
||||
def _build(self, sections: SectionDict) -> None:
|
||||
start_line_number = 1
|
||||
dummy_handle = 1
|
||||
entity_index: dict[str, IndexEntry] = dict()
|
||||
dummy_handle_index: dict[int, str] = dict()
|
||||
prev_entry: Optional[IndexEntry] = None
|
||||
for section in sections.values():
|
||||
for tags in section:
|
||||
assert isinstance(tags, Tags), "expected class Tags"
|
||||
assert len(tags) > 0, "empty tags should not be possible"
|
||||
try:
|
||||
handle = tags.get_handle().upper()
|
||||
except ValueError:
|
||||
handle = f"*{dummy_handle:X}"
|
||||
# index dummy handle by id of the first tag:
|
||||
dummy_handle_index[id(tags[0])] = handle
|
||||
dummy_handle += 1
|
||||
|
||||
next_entry = IndexEntry(tags, start_line_number)
|
||||
if prev_entry is not None:
|
||||
next_entry.prev = prev_entry
|
||||
prev_entry.next = next_entry
|
||||
entity_index[handle] = next_entry
|
||||
prev_entry = next_entry
|
||||
|
||||
# calculate next start line number:
|
||||
# add 2 lines for each tag: group code, value
|
||||
start_line_number += len(tags) * 2
|
||||
start_line_number += 2 # for removed ENDSEC tag
|
||||
|
||||
# subtract 1 and 2 for the last ENDSEC tag!
|
||||
self._max_line_number = start_line_number - 3
|
||||
self._index = entity_index
|
||||
self._dummy_handle_index = dummy_handle_index
|
||||
|
||||
def __contains__(self, handle: str) -> bool:
|
||||
return handle.upper() in self._index
|
||||
|
||||
@property
|
||||
def max_line_number(self) -> int:
|
||||
return self._max_line_number
|
||||
|
||||
def get(self, handle: str) -> Optional[Tags]:
|
||||
index_entry = self._index.get(handle.upper())
|
||||
if index_entry is not None:
|
||||
return index_entry.tags
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_handle(self, entity: Tags) -> Optional[str]:
|
||||
if not len(entity):
|
||||
return None
|
||||
|
||||
try:
|
||||
return entity.get_handle()
|
||||
except ValueError:
|
||||
# fast retrieval of dummy handle which isn't stored in tags:
|
||||
return self._dummy_handle_index.get(id(entity[0]))
|
||||
|
||||
def next_entity(self, entity: Tags) -> Tags:
|
||||
handle = self.get_handle(entity)
|
||||
if handle:
|
||||
index_entry = self._index.get(handle)
|
||||
next_entry = index_entry.next # type: ignore
|
||||
# next of last entity is None!
|
||||
if next_entry:
|
||||
return next_entry.tags
|
||||
return entity
|
||||
|
||||
def previous_entity(self, entity: Tags) -> Tags:
|
||||
handle = self.get_handle(entity)
|
||||
if handle:
|
||||
index_entry = self._index.get(handle)
|
||||
prev_entry = index_entry.prev # type: ignore
|
||||
# prev of first entity is None!
|
||||
if prev_entry:
|
||||
return prev_entry.tags
|
||||
return entity
|
||||
|
||||
def get_start_line_for_entity(self, entity: Tags) -> int:
|
||||
handle = self.get_handle(entity)
|
||||
if handle:
|
||||
index_entry = self._index.get(handle)
|
||||
if index_entry:
|
||||
return index_entry.start_line_number
|
||||
return 0
|
||||
|
||||
def get_entity_at_line(self, number: int) -> Optional[Tags]:
|
||||
tags = None
|
||||
for index_entry in self._index.values():
|
||||
if index_entry.start_line_number > number:
|
||||
return tags # tags of previous entry!
|
||||
tags = index_entry.tags
|
||||
return tags
|
||||
|
||||
|
||||
def get_row_from_line_number(
|
||||
entity: Tags, start_line_number: int, select_line_number: int
|
||||
) -> int:
|
||||
count = select_line_number - start_line_number
|
||||
lines = 0
|
||||
row = 0
|
||||
for tag in entity:
|
||||
if lines >= count:
|
||||
return row
|
||||
if isinstance(tag, DXFVertex):
|
||||
lines += len(tag.value) * 2
|
||||
else:
|
||||
lines += 2
|
||||
row += 1
|
||||
return row
|
||||
|
||||
|
||||
def dxfstr(tags: Tags) -> str:
|
||||
return "".join(tag.dxfstr() for tag in tags)
|
||||
|
||||
|
||||
class EntityHistory:
|
||||
def __init__(self) -> None:
|
||||
self._history: list[Tags] = list()
|
||||
self._index: int = 0
|
||||
self._time_travel: list[Tags] = list()
|
||||
|
||||
def __len__(self):
|
||||
return len(self._history)
|
||||
|
||||
@property
|
||||
def index(self):
|
||||
return self._index
|
||||
|
||||
def clear(self):
|
||||
self._history.clear()
|
||||
self._time_travel.clear()
|
||||
self._index = 0
|
||||
|
||||
def append(self, entity: Tags):
|
||||
if self._time_travel:
|
||||
self._history.extend(self._time_travel)
|
||||
self._time_travel.clear()
|
||||
count = len(self._history)
|
||||
if count:
|
||||
# only append if different to last entity
|
||||
if self._history[-1] is entity:
|
||||
return
|
||||
self._index = count
|
||||
self._history.append(entity)
|
||||
|
||||
def back(self) -> Optional[Tags]:
|
||||
entity = None
|
||||
if self._history:
|
||||
index = self._index - 1
|
||||
if index >= 0:
|
||||
entity = self._time_wrap(index)
|
||||
else:
|
||||
entity = self._history[0]
|
||||
return entity
|
||||
|
||||
def forward(self) -> Tags:
|
||||
entity = None
|
||||
history = self._history
|
||||
if history:
|
||||
index = self._index + 1
|
||||
if index < len(history):
|
||||
entity = self._time_wrap(index)
|
||||
else:
|
||||
entity = history[-1]
|
||||
return entity # type: ignore
|
||||
|
||||
def _time_wrap(self, index) -> Tags:
|
||||
self._index = index
|
||||
entity = self._history[index]
|
||||
self._time_travel.append(entity)
|
||||
return entity
|
||||
|
||||
def content(self) -> list[Tags]:
|
||||
return list(self._history)
|
||||
|
||||
|
||||
class SearchIndex:
|
||||
NOT_FOUND = None, -1
|
||||
|
||||
def __init__(self, entities: Iterable[Tags]):
|
||||
self.entities: list[Tags] = list(entities)
|
||||
self._current_entity_index: int = 0
|
||||
self._current_tag_index: int = 0
|
||||
self._search_term: Optional[str] = None
|
||||
self._search_term_lower: Optional[str] = None
|
||||
self._backward = False
|
||||
self._end_of_index = not bool(self.entities)
|
||||
self.case_insensitive = True
|
||||
self.whole_words = False
|
||||
self.numbers = False
|
||||
self.regex = False # False = normal mode
|
||||
|
||||
@property
|
||||
def is_end_of_index(self) -> bool:
|
||||
return self._end_of_index
|
||||
|
||||
@property
|
||||
def search_term(self) -> Optional[str]:
|
||||
return self._search_term
|
||||
|
||||
def set_current_entity(self, entity: Tags, tag_index: int = 0):
|
||||
self._current_tag_index = tag_index
|
||||
try:
|
||||
self._current_entity_index = self.entities.index(entity)
|
||||
except ValueError:
|
||||
self.reset_cursor()
|
||||
|
||||
def update_entities(self, entities: list[Tags]):
|
||||
current_entity, index = self.current_entity()
|
||||
self.entities = entities
|
||||
if current_entity:
|
||||
self.set_current_entity(current_entity, index)
|
||||
|
||||
def current_entity(self) -> tuple[Optional[Tags], int]:
|
||||
if self.entities and not self._end_of_index:
|
||||
return (
|
||||
self.entities[self._current_entity_index],
|
||||
self._current_tag_index,
|
||||
)
|
||||
return self.NOT_FOUND
|
||||
|
||||
def reset_cursor(self, backward: bool = False):
|
||||
self._current_entity_index = 0
|
||||
self._current_tag_index = 0
|
||||
count = len(self.entities)
|
||||
if count:
|
||||
self._end_of_index = False
|
||||
if backward:
|
||||
self._current_entity_index = count - 1
|
||||
entity = self.entities[-1]
|
||||
self._current_tag_index = len(entity) - 1
|
||||
else:
|
||||
self._end_of_index = True
|
||||
|
||||
def cursor(self) -> tuple[int, int]:
|
||||
return self._current_entity_index, self._current_tag_index
|
||||
|
||||
def move_cursor_forward(self) -> None:
|
||||
if self.entities:
|
||||
entity: Tags = self.entities[self._current_entity_index]
|
||||
tag_index = self._current_tag_index + 1
|
||||
if tag_index >= len(entity):
|
||||
entity_index = self._current_entity_index + 1
|
||||
if entity_index < len(self.entities):
|
||||
self._current_entity_index = entity_index
|
||||
self._current_tag_index = 0
|
||||
else:
|
||||
self._end_of_index = True
|
||||
else:
|
||||
self._current_tag_index = tag_index
|
||||
|
||||
def move_cursor_backward(self) -> None:
|
||||
if self.entities:
|
||||
tag_index = self._current_tag_index - 1
|
||||
if tag_index < 0:
|
||||
entity_index = self._current_entity_index - 1
|
||||
if entity_index >= 0:
|
||||
self._current_entity_index = entity_index
|
||||
self._current_tag_index = (
|
||||
len(self.entities[entity_index]) - 1
|
||||
)
|
||||
else:
|
||||
self._end_of_index = True
|
||||
else:
|
||||
self._current_tag_index = tag_index
|
||||
|
||||
def reset_search_term(self, term: str) -> None:
|
||||
self._search_term = str(term)
|
||||
self._search_term_lower = self._search_term.lower()
|
||||
|
||||
def find(
|
||||
self, term: str, backward: bool = False, reset_index: bool = True
|
||||
) -> tuple[Optional[Tags], int]:
|
||||
self.reset_search_term(term)
|
||||
if reset_index:
|
||||
self.reset_cursor(backward)
|
||||
if len(self.entities) and not self._end_of_index:
|
||||
if backward:
|
||||
return self.find_backwards()
|
||||
else:
|
||||
return self.find_forward()
|
||||
else:
|
||||
return self.NOT_FOUND
|
||||
|
||||
def find_forward(self) -> tuple[Optional[Tags], int]:
|
||||
return self._find(self.move_cursor_forward)
|
||||
|
||||
def find_backwards(self) -> tuple[Optional[Tags], int]:
|
||||
return self._find(self.move_cursor_backward)
|
||||
|
||||
def _find(self, move_cursor) -> tuple[Optional[Tags], int]:
|
||||
if self.entities and self._search_term and not self._end_of_index:
|
||||
while not self._end_of_index:
|
||||
entity, tag_index = self.current_entity()
|
||||
move_cursor()
|
||||
if self._match(*entity[tag_index]): # type: ignore
|
||||
return entity, tag_index
|
||||
return self.NOT_FOUND
|
||||
|
||||
def _match(self, code: int, value: Any) -> bool:
|
||||
if tag_type(code) is not str:
|
||||
if not self.numbers:
|
||||
return False
|
||||
value = str(value)
|
||||
|
||||
if self.case_insensitive:
|
||||
search_term = self._search_term_lower
|
||||
value = value.lower()
|
||||
else:
|
||||
search_term = self._search_term
|
||||
|
||||
if self.whole_words:
|
||||
return any(search_term == word for word in value.split())
|
||||
else:
|
||||
return search_term in value
|
||||
@@ -0,0 +1,171 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Form implementation generated from reading ui file 'find_dialog.ui'
|
||||
#
|
||||
# Created by: PyQt5 UI code generator 5.15.2
|
||||
#
|
||||
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
|
||||
# run again. Do not edit this file unless you know what you are doing.
|
||||
|
||||
|
||||
from ezdxf.addons.xqt import QtCore, QtGui, QtWidgets
|
||||
|
||||
|
||||
class Ui_FindDialog(object):
|
||||
def setupUi(self, FindDialog):
|
||||
FindDialog.setObjectName("FindDialog")
|
||||
FindDialog.resize(320, 376)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(FindDialog.sizePolicy().hasHeightForWidth())
|
||||
FindDialog.setSizePolicy(sizePolicy)
|
||||
FindDialog.setMinimumSize(QtCore.QSize(320, 376))
|
||||
FindDialog.setMaximumSize(QtCore.QSize(320, 376))
|
||||
FindDialog.setBaseSize(QtCore.QSize(320, 376))
|
||||
self.verticalLayout_5 = QtWidgets.QVBoxLayout(FindDialog)
|
||||
self.verticalLayout_5.setObjectName("verticalLayout_5")
|
||||
self.verticalLayout = QtWidgets.QVBoxLayout()
|
||||
self.verticalLayout.setObjectName("verticalLayout")
|
||||
self.horizontalLayout = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout.setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
|
||||
self.horizontalLayout.setObjectName("horizontalLayout")
|
||||
self.label = QtWidgets.QLabel(FindDialog)
|
||||
self.label.setObjectName("label")
|
||||
self.horizontalLayout.addWidget(self.label)
|
||||
self.find_text_edit = QtWidgets.QLineEdit(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.find_text_edit.sizePolicy().hasHeightForWidth())
|
||||
self.find_text_edit.setSizePolicy(sizePolicy)
|
||||
self.find_text_edit.setMinimumSize(QtCore.QSize(0, 24))
|
||||
self.find_text_edit.setMaximumSize(QtCore.QSize(16777215, 24))
|
||||
self.find_text_edit.setObjectName("find_text_edit")
|
||||
self.horizontalLayout.addWidget(self.find_text_edit)
|
||||
self.verticalLayout.addLayout(self.horizontalLayout)
|
||||
self.groupBox = QtWidgets.QGroupBox(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.groupBox.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox.setSizePolicy(sizePolicy)
|
||||
self.groupBox.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
|
||||
self.groupBox.setFlat(False)
|
||||
self.groupBox.setObjectName("groupBox")
|
||||
self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.groupBox)
|
||||
self.verticalLayout_3.setObjectName("verticalLayout_3")
|
||||
self.whole_words_check_box = QtWidgets.QCheckBox(self.groupBox)
|
||||
self.whole_words_check_box.setObjectName("whole_words_check_box")
|
||||
self.verticalLayout_3.addWidget(self.whole_words_check_box)
|
||||
self.match_case_check_box = QtWidgets.QCheckBox(self.groupBox)
|
||||
self.match_case_check_box.setObjectName("match_case_check_box")
|
||||
self.verticalLayout_3.addWidget(self.match_case_check_box)
|
||||
self.number_tags_check_box = QtWidgets.QCheckBox(self.groupBox)
|
||||
self.number_tags_check_box.setObjectName("number_tags_check_box")
|
||||
self.verticalLayout_3.addWidget(self.number_tags_check_box)
|
||||
self.verticalLayout.addWidget(self.groupBox, 0, QtCore.Qt.AlignTop)
|
||||
self.groupBox_2 = QtWidgets.QGroupBox(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_2.setSizePolicy(sizePolicy)
|
||||
self.groupBox_2.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
|
||||
self.groupBox_2.setObjectName("groupBox_2")
|
||||
self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.groupBox_2)
|
||||
self.verticalLayout_4.setObjectName("verticalLayout_4")
|
||||
self.header_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.header_check_box.setChecked(True)
|
||||
self.header_check_box.setObjectName("header_check_box")
|
||||
self.verticalLayout_4.addWidget(self.header_check_box)
|
||||
self.classes_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.classes_check_box.setObjectName("classes_check_box")
|
||||
self.verticalLayout_4.addWidget(self.classes_check_box)
|
||||
self.tables_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.tables_check_box.setChecked(True)
|
||||
self.tables_check_box.setObjectName("tables_check_box")
|
||||
self.verticalLayout_4.addWidget(self.tables_check_box)
|
||||
self.blocks_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.blocks_check_box.setChecked(True)
|
||||
self.blocks_check_box.setObjectName("blocks_check_box")
|
||||
self.verticalLayout_4.addWidget(self.blocks_check_box)
|
||||
self.entities_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.entities_check_box.setChecked(True)
|
||||
self.entities_check_box.setObjectName("entities_check_box")
|
||||
self.verticalLayout_4.addWidget(self.entities_check_box)
|
||||
self.objects_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.objects_check_box.setChecked(False)
|
||||
self.objects_check_box.setObjectName("objects_check_box")
|
||||
self.verticalLayout_4.addWidget(self.objects_check_box)
|
||||
self.verticalLayout.addWidget(self.groupBox_2, 0, QtCore.Qt.AlignTop)
|
||||
self.verticalLayout_5.addLayout(self.verticalLayout)
|
||||
self.message = QtWidgets.QLabel(FindDialog)
|
||||
self.message.setObjectName("message")
|
||||
self.verticalLayout_5.addWidget(self.message)
|
||||
self.buttons_layout = QtWidgets.QHBoxLayout()
|
||||
self.buttons_layout.setSizeConstraint(QtWidgets.QLayout.SetNoConstraint)
|
||||
self.buttons_layout.setObjectName("buttons_layout")
|
||||
self.find_forward_button = QtWidgets.QPushButton(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.find_forward_button.sizePolicy().hasHeightForWidth())
|
||||
self.find_forward_button.setSizePolicy(sizePolicy)
|
||||
self.find_forward_button.setMinimumSize(QtCore.QSize(0, 0))
|
||||
self.find_forward_button.setMaximumSize(QtCore.QSize(200, 100))
|
||||
self.find_forward_button.setObjectName("find_forward_button")
|
||||
self.buttons_layout.addWidget(self.find_forward_button, 0, QtCore.Qt.AlignBottom)
|
||||
self.find_backwards_button = QtWidgets.QPushButton(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.find_backwards_button.sizePolicy().hasHeightForWidth())
|
||||
self.find_backwards_button.setSizePolicy(sizePolicy)
|
||||
self.find_backwards_button.setMinimumSize(QtCore.QSize(0, 0))
|
||||
self.find_backwards_button.setMaximumSize(QtCore.QSize(200, 100))
|
||||
self.find_backwards_button.setObjectName("find_backwards_button")
|
||||
self.buttons_layout.addWidget(self.find_backwards_button, 0, QtCore.Qt.AlignBottom)
|
||||
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
|
||||
self.buttons_layout.addItem(spacerItem)
|
||||
self.close_button = QtWidgets.QPushButton(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.close_button.sizePolicy().hasHeightForWidth())
|
||||
self.close_button.setSizePolicy(sizePolicy)
|
||||
self.close_button.setMinimumSize(QtCore.QSize(0, 0))
|
||||
self.close_button.setMaximumSize(QtCore.QSize(200, 100))
|
||||
self.close_button.setToolTip("")
|
||||
self.close_button.setObjectName("close_button")
|
||||
self.buttons_layout.addWidget(self.close_button, 0, QtCore.Qt.AlignRight|QtCore.Qt.AlignBottom)
|
||||
self.verticalLayout_5.addLayout(self.buttons_layout)
|
||||
|
||||
self.retranslateUi(FindDialog)
|
||||
QtCore.QMetaObject.connectSlotsByName(FindDialog)
|
||||
|
||||
def retranslateUi(self, FindDialog):
|
||||
_translate = QtCore.QCoreApplication.translate
|
||||
FindDialog.setWindowTitle(_translate("FindDialog", "Find"))
|
||||
self.label.setText(_translate("FindDialog", "Find Text:"))
|
||||
self.groupBox.setTitle(_translate("FindDialog", "Options"))
|
||||
self.whole_words_check_box.setToolTip(_translate("FindDialog", "Search only whole words in normal mode if checked."))
|
||||
self.whole_words_check_box.setText(_translate("FindDialog", "Whole Words"))
|
||||
self.match_case_check_box.setToolTip(_translate("FindDialog", "Case sensitive search in normal mode if checked."))
|
||||
self.match_case_check_box.setText(_translate("FindDialog", "Match Case"))
|
||||
self.number_tags_check_box.setToolTip(_translate("FindDialog", "Ignore numeric DXF tags if checked."))
|
||||
self.number_tags_check_box.setText(_translate("FindDialog", "Search in Numeric Tags"))
|
||||
self.groupBox_2.setToolTip(_translate("FindDialog", "Select sections to search in."))
|
||||
self.groupBox_2.setTitle(_translate("FindDialog", "Search in Sections"))
|
||||
self.header_check_box.setText(_translate("FindDialog", "HEADER"))
|
||||
self.classes_check_box.setText(_translate("FindDialog", "CLASSES"))
|
||||
self.tables_check_box.setText(_translate("FindDialog", "TABLES"))
|
||||
self.blocks_check_box.setText(_translate("FindDialog", "BLOCKS"))
|
||||
self.entities_check_box.setText(_translate("FindDialog", "ENTITIES"))
|
||||
self.objects_check_box.setText(_translate("FindDialog", "OBJECTS"))
|
||||
self.message.setText(_translate("FindDialog", "TextLabel"))
|
||||
self.find_forward_button.setToolTip(_translate("FindDialog", "or press F3"))
|
||||
self.find_forward_button.setText(_translate("FindDialog", "Find &Forward"))
|
||||
self.find_backwards_button.setToolTip(_translate("FindDialog", "or press F4"))
|
||||
self.find_backwards_button.setText(_translate("FindDialog", "Find &Backwards"))
|
||||
self.close_button.setText(_translate("FindDialog", "Close"))
|
||||
@@ -0,0 +1,36 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Union, Iterable, TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
from ezdxf.lldxf import loader
|
||||
from ezdxf.lldxf.types import DXFTag
|
||||
from ezdxf.lldxf.tagger import ascii_tags_loader, binary_tags_loader
|
||||
from ezdxf.lldxf.validator import is_dxf_file, is_binary_dxf_file
|
||||
from ezdxf.filemanagement import dxf_file_info
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.eztypes import SectionDict
|
||||
|
||||
|
||||
def load_section_dict(filename: Union[str, Path]) -> SectionDict:
|
||||
tagger = get_tag_loader(filename)
|
||||
return loader.load_dxf_structure(tagger)
|
||||
|
||||
|
||||
def get_tag_loader(
|
||||
filename: Union[str, Path], errors: str = "ignore"
|
||||
) -> Iterable[DXFTag]:
|
||||
|
||||
filename = str(filename)
|
||||
if is_binary_dxf_file(filename):
|
||||
with open(filename, "rb") as fp:
|
||||
data = fp.read()
|
||||
return binary_tags_loader(data, errors=errors)
|
||||
|
||||
if not is_dxf_file(filename):
|
||||
raise IOError(f"File '{filename}' is not a DXF file.")
|
||||
|
||||
info = dxf_file_info(filename)
|
||||
with open(filename, mode="rt", encoding=info.encoding, errors=errors) as fp:
|
||||
return list(ascii_tags_loader(fp, skip_comments=True))
|
||||
@@ -0,0 +1,574 @@
|
||||
# Copyright (c) 2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# mypy: ignore_errors=True
|
||||
from __future__ import annotations
|
||||
from typing import Any, Optional
|
||||
import textwrap
|
||||
from ezdxf.lldxf.types import (
|
||||
render_tag,
|
||||
DXFVertex,
|
||||
GROUP_MARKERS,
|
||||
POINTER_CODES,
|
||||
)
|
||||
from ezdxf.addons.xqt import QModelIndex, QAbstractTableModel, Qt, QtWidgets
|
||||
from ezdxf.addons.xqt import QStandardItemModel, QStandardItem, QColor
|
||||
from .tags import compile_tags, Tags
|
||||
|
||||
__all__ = [
|
||||
"DXFTagsModel",
|
||||
"DXFStructureModel",
|
||||
"EntityContainer",
|
||||
"Entity",
|
||||
"DXFTagsRole",
|
||||
]
|
||||
|
||||
DXFTagsRole = Qt.UserRole + 1 # type: ignore
|
||||
|
||||
|
||||
def name_fmt(handle, name: str) -> str:
|
||||
if handle is None:
|
||||
return name
|
||||
else:
|
||||
return f"<{handle}> {name}"
|
||||
|
||||
|
||||
HEADER_LABELS = ["Group Code", "Data Type", "Content", "4", "5"]
|
||||
|
||||
|
||||
def calc_line_numbers(start: int, tags: Tags) -> list[int]:
|
||||
numbers = [start]
|
||||
index = start
|
||||
for tag in tags:
|
||||
if isinstance(tag, DXFVertex):
|
||||
index += len(tag.value) * 2
|
||||
else:
|
||||
index += 2
|
||||
numbers.append(index)
|
||||
return numbers
|
||||
|
||||
|
||||
class DXFTagsModel(QAbstractTableModel):
|
||||
def __init__(
|
||||
self, tags: Tags, start_line_number: int = 1, valid_handles=None
|
||||
):
|
||||
super().__init__()
|
||||
self._tags = compile_tags(tags)
|
||||
self._line_numbers = calc_line_numbers(start_line_number, self._tags)
|
||||
self._valid_handles = valid_handles or set()
|
||||
palette = QtWidgets.QApplication.palette()
|
||||
self._group_marker_color = palette.highlight().color()
|
||||
|
||||
def data(self, index: QModelIndex, role: int = ...) -> Any: # type: ignore
|
||||
def is_invalid_handle(tag):
|
||||
if (
|
||||
tag.code in POINTER_CODES
|
||||
and not tag.value.upper() in self._valid_handles
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
if role == Qt.DisplayRole:
|
||||
tag = self._tags[index.row()]
|
||||
return render_tag(tag, index.column())
|
||||
elif role == Qt.ForegroundRole:
|
||||
tag = self._tags[index.row()]
|
||||
if tag.code in GROUP_MARKERS:
|
||||
return self._group_marker_color
|
||||
elif is_invalid_handle(tag):
|
||||
return QColor("red")
|
||||
elif role == DXFTagsRole:
|
||||
return self._tags[index.row()]
|
||||
elif role == Qt.ToolTipRole:
|
||||
code, value = self._tags[index.row()]
|
||||
if index.column() == 0: # group code column
|
||||
return GROUP_CODE_TOOLTIPS_DICT.get(code)
|
||||
|
||||
code, value = self._tags[index.row()]
|
||||
if code in POINTER_CODES:
|
||||
if value.upper() in self._valid_handles:
|
||||
return f"Double click to go to the referenced entity"
|
||||
else:
|
||||
return f"Handle does not exist"
|
||||
elif code == 0:
|
||||
return f"Double click to go to the DXF reference provided by Autodesk"
|
||||
|
||||
def headerData(
|
||||
self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore
|
||||
) -> Any:
|
||||
if orientation == Qt.Horizontal:
|
||||
if role == Qt.DisplayRole:
|
||||
return HEADER_LABELS[section]
|
||||
elif role == Qt.TextAlignmentRole:
|
||||
return Qt.AlignLeft
|
||||
elif orientation == Qt.Vertical:
|
||||
if role == Qt.DisplayRole:
|
||||
return self._line_numbers[section]
|
||||
elif role == Qt.ToolTipRole:
|
||||
return "Line number in DXF file"
|
||||
|
||||
def rowCount(self, parent: QModelIndex = ...) -> int: # type: ignore
|
||||
return len(self._tags)
|
||||
|
||||
def columnCount(self, parent: QModelIndex = ...) -> int: # type: ignore
|
||||
return 3
|
||||
|
||||
def compiled_tags(self) -> Tags:
|
||||
"""Returns the compiled tags. Only points codes are compiled, group
|
||||
code 10, ...
|
||||
"""
|
||||
return self._tags
|
||||
|
||||
def line_number(self, row: int) -> int:
|
||||
"""Return the DXF file line number of the widget-row."""
|
||||
try:
|
||||
return self._line_numbers[row]
|
||||
except IndexError:
|
||||
return 0
|
||||
|
||||
|
||||
class EntityContainer(QStandardItem):
|
||||
def __init__(self, name: str, entities: list[Tags]):
|
||||
super().__init__()
|
||||
self.setEditable(False)
|
||||
self.setText(name + f" ({len(entities)})")
|
||||
self.setup_content(entities)
|
||||
|
||||
def setup_content(self, entities):
|
||||
self.appendRows([Entity(e) for e in entities])
|
||||
|
||||
|
||||
class Classes(EntityContainer):
|
||||
def setup_content(self, entities):
|
||||
self.appendRows([Class(e) for e in entities])
|
||||
|
||||
|
||||
class AcDsData(EntityContainer):
|
||||
def setup_content(self, entities):
|
||||
self.appendRows([AcDsEntry(e) for e in entities])
|
||||
|
||||
|
||||
class NamedEntityContainer(EntityContainer):
|
||||
def setup_content(self, entities):
|
||||
self.appendRows([NamedEntity(e) for e in entities])
|
||||
|
||||
|
||||
class Tables(EntityContainer):
|
||||
def setup_content(self, entities):
|
||||
container = []
|
||||
name = ""
|
||||
for e in entities:
|
||||
container.append(e)
|
||||
dxftype = e.dxftype()
|
||||
if dxftype == "TABLE":
|
||||
try:
|
||||
handle = e.get_handle()
|
||||
except ValueError:
|
||||
handle = None
|
||||
name = e.get_first_value(2, default="UNDEFINED")
|
||||
name = name_fmt(handle, name)
|
||||
elif dxftype == "ENDTAB":
|
||||
if container:
|
||||
container.pop() # remove ENDTAB
|
||||
self.appendRow(NamedEntityContainer(name, container))
|
||||
container.clear()
|
||||
|
||||
|
||||
class Blocks(EntityContainer):
|
||||
def setup_content(self, entities):
|
||||
container = []
|
||||
name = "UNDEFINED"
|
||||
for e in entities:
|
||||
container.append(e)
|
||||
dxftype = e.dxftype()
|
||||
if dxftype == "BLOCK":
|
||||
try:
|
||||
handle = e.get_handle()
|
||||
except ValueError:
|
||||
handle = None
|
||||
name = e.get_first_value(2, default="UNDEFINED")
|
||||
name = name_fmt(handle, name)
|
||||
elif dxftype == "ENDBLK":
|
||||
if container:
|
||||
self.appendRow(EntityContainer(name, container))
|
||||
container.clear()
|
||||
|
||||
|
||||
def get_section_name(section: list[Tags]) -> str:
|
||||
if len(section) > 0:
|
||||
header = section[0]
|
||||
if len(header) > 1 and header[0].code == 0 and header[1].code == 2:
|
||||
return header[1].value
|
||||
return "INVALID SECTION HEADER!"
|
||||
|
||||
|
||||
class Entity(QStandardItem):
|
||||
def __init__(self, tags: Tags):
|
||||
super().__init__()
|
||||
self.setEditable(False)
|
||||
self._tags = tags
|
||||
self._handle: Optional[str]
|
||||
try:
|
||||
self._handle = tags.get_handle()
|
||||
except ValueError:
|
||||
self._handle = None
|
||||
self.setText(self.entity_name())
|
||||
|
||||
def entity_name(self):
|
||||
name = "INVALID ENTITY!"
|
||||
tags = self._tags
|
||||
if tags and tags[0].code == 0:
|
||||
name = name_fmt(self._handle, tags[0].value)
|
||||
return name
|
||||
|
||||
def data(self, role: int = ...) -> Any: # type: ignore
|
||||
if role == DXFTagsRole:
|
||||
return self._tags
|
||||
else:
|
||||
return super().data(role)
|
||||
|
||||
|
||||
class Header(Entity):
|
||||
def entity_name(self):
|
||||
return "HEADER"
|
||||
|
||||
|
||||
class ThumbnailImage(Entity):
|
||||
def entity_name(self):
|
||||
return "THUMBNAILIMAGE"
|
||||
|
||||
|
||||
class NamedEntity(Entity):
|
||||
def entity_name(self):
|
||||
name = self._tags.get_first_value(2, "<noname>")
|
||||
return name_fmt(str(self._handle), name)
|
||||
|
||||
|
||||
class Class(Entity):
|
||||
def entity_name(self):
|
||||
tags = self._tags
|
||||
name = "INVALID CLASS!"
|
||||
if len(tags) > 1 and tags[0].code == 0 and tags[1].code == 1:
|
||||
name = tags[1].value
|
||||
return name
|
||||
|
||||
|
||||
class AcDsEntry(Entity):
|
||||
def entity_name(self):
|
||||
return self._tags[0].value
|
||||
|
||||
|
||||
class DXFStructureModel(QStandardItemModel):
|
||||
def __init__(self, filename: str, doc):
|
||||
super().__init__()
|
||||
root = QStandardItem(filename)
|
||||
root.setEditable(False)
|
||||
self.appendRow(root)
|
||||
row: Any
|
||||
for section in doc.sections.values():
|
||||
name = get_section_name(section)
|
||||
if name == "HEADER":
|
||||
row = Header(section[0])
|
||||
elif name == "THUMBNAILIMAGE":
|
||||
row = ThumbnailImage(section[0])
|
||||
elif name == "CLASSES":
|
||||
row = Classes(name, section[1:])
|
||||
elif name == "TABLES":
|
||||
row = Tables(name, section[1:])
|
||||
elif name == "BLOCKS":
|
||||
row = Blocks(name, section[1:])
|
||||
elif name == "ACDSDATA":
|
||||
row = AcDsData(name, section[1:])
|
||||
else:
|
||||
row = EntityContainer(name, section[1:])
|
||||
root.appendRow(row)
|
||||
|
||||
def index_of_entity(self, entity: Tags) -> QModelIndex:
|
||||
root = self.item(0, 0)
|
||||
index = find_index(root, entity)
|
||||
if index is None:
|
||||
return root.index()
|
||||
else:
|
||||
return index
|
||||
|
||||
|
||||
def find_index(item: QStandardItem, entity: Tags) -> Optional[QModelIndex]:
|
||||
def _find(sub_item: QStandardItem):
|
||||
for index in range(sub_item.rowCount()):
|
||||
child = sub_item.child(index, 0)
|
||||
tags = child.data(DXFTagsRole)
|
||||
if tags and tags is entity:
|
||||
return child.index()
|
||||
if child.rowCount() > 0:
|
||||
index2 = _find(child)
|
||||
if index2 is not None:
|
||||
return index2
|
||||
return None
|
||||
|
||||
return _find(item)
|
||||
|
||||
|
||||
GROUP_CODE_TOOLTIPS = [
|
||||
(0, "Text string indicating the entity type (fixed)"),
|
||||
(1, "Primary text value for an entity"),
|
||||
(2, "Name (attribute tag, block name, and so on)"),
|
||||
((3, 4), "Other text or name values"),
|
||||
(5, "Entity handle; text string of up to 16 hexadecimal digits (fixed)"),
|
||||
(6, "Linetype name (fixed)"),
|
||||
(7, "Text style name (fixed)"),
|
||||
(8, "Layer name (fixed)"),
|
||||
(
|
||||
9,
|
||||
"DXF: variable name identifier (used only in HEADER section of the DXF file)",
|
||||
),
|
||||
(
|
||||
10,
|
||||
"Primary point; this is the start point of a line or text entity, center "
|
||||
"of a circle, and so on DXF: X value of the primary point (followed by Y "
|
||||
"and Z value codes 20 and 30) APP: 3D point (list of three reals)",
|
||||
),
|
||||
(
|
||||
(11, 18),
|
||||
"Other points DXF: X value of other points (followed by Y value codes "
|
||||
"21-28 and Z value codes 31-38) APP: 3D point (list of three reals)",
|
||||
),
|
||||
(20, "DXF: Y value of the primary point"),
|
||||
(30, "DXF: Z value of the primary point"),
|
||||
((21, 28), "DXF: Y values of other points"),
|
||||
((31, 37), "DXF: Z values of other points"),
|
||||
(38, "DXF: entity's elevation if nonzero"),
|
||||
(39, "Entity's thickness if nonzero (fixed)"),
|
||||
(
|
||||
(40, 47),
|
||||
"Double-precision floating-point values (text height, scale factors, and so on)",
|
||||
),
|
||||
(48, "Linetype scale; default value is defined for all entity types"),
|
||||
(
|
||||
49,
|
||||
"Multiple 49 groups may appear in one entity for variable-length tables "
|
||||
"(such as the dash lengths in the LTYPE table). A 7x group always appears "
|
||||
"before the first 49 group to specify the table length",
|
||||
),
|
||||
(
|
||||
(50, 58),
|
||||
"Angles (output in degrees to DXF files and radians through AutoLISP and ObjectARX applications)",
|
||||
),
|
||||
(
|
||||
60,
|
||||
"Entity visibility; absence or 0 indicates visibility; 1 indicates invisibility",
|
||||
),
|
||||
(62, "Color number (fixed)"),
|
||||
(66, "Entities follow flag (fixed)"),
|
||||
(67, "0 for model space or 1 for paper space (fixed)"),
|
||||
(
|
||||
68,
|
||||
"APP: identifies whether viewport is on but fully off screen; is not active or is off",
|
||||
),
|
||||
(69, "APP: viewport identification number"),
|
||||
((70, 79), "Integer values, such as repeat counts, flag bits, or modes"),
|
||||
((90, 99), "32-bit integer values"),
|
||||
(
|
||||
100,
|
||||
"Subclass data marker (with derived class name as a string). "
|
||||
"Required for all objects and entity classes that are derived from "
|
||||
"another concrete class. The subclass data marker segregates data defined by different "
|
||||
"classes in the inheritance chain for the same object. This is in addition "
|
||||
"to the requirement for DXF names for each distinct concrete class derived "
|
||||
"from ObjectARX (see Subclass Markers)",
|
||||
),
|
||||
(101, "Embedded object marker"),
|
||||
(
|
||||
102,
|
||||
"Control string, followed by '{arbitrary name' or '}'. Similar to the "
|
||||
"xdata 1002 group code, except that when the string begins with '{', it "
|
||||
"can be followed by an arbitrary string whose interpretation is up to the "
|
||||
"application. The only other control string allowed is '}' as a group "
|
||||
"terminator. AutoCAD does not interpret these strings except during d"
|
||||
"rawing audit operations. They are for application use.",
|
||||
),
|
||||
(105, "Object handle for DIMVAR symbol table entry"),
|
||||
(
|
||||
110,
|
||||
"UCS origin (appears only if code 72 is set to 1); DXF: X value; APP: 3D point",
|
||||
),
|
||||
(
|
||||
111,
|
||||
"UCS Y-axis (appears only if code 72 is set to 1); DXF: Y value; APP: 3D vector",
|
||||
),
|
||||
(
|
||||
112,
|
||||
"UCS Z-axis (appears only if code 72 is set to 1); DXF: Z value; APP: 3D vector",
|
||||
),
|
||||
((120, 122), "DXF: Y value of UCS origin, UCS X-axis, and UCS Y-axis"),
|
||||
((130, 132), "DXF: Z value of UCS origin, UCS X-axis, and UCS Y-axis"),
|
||||
(
|
||||
(140, 149),
|
||||
"Double-precision floating-point values (points, elevation, and DIMSTYLE settings, for example)",
|
||||
),
|
||||
(
|
||||
(170, 179),
|
||||
"16-bit integer values, such as flag bits representing DIMSTYLE settings",
|
||||
),
|
||||
(
|
||||
210,
|
||||
"Extrusion direction (fixed) "
|
||||
+ "DXF: X value of extrusion direction "
|
||||
+ "APP: 3D extrusion direction vector",
|
||||
),
|
||||
(220, "DXF: Y value of the extrusion direction"),
|
||||
(230, "DXF: Z value of the extrusion direction"),
|
||||
((270, 279), "16-bit integer values"),
|
||||
((280, 289), "16-bit integer value"),
|
||||
((290, 299), "Boolean flag value; 0 = False; 1 = True"),
|
||||
((300, 309), "Arbitrary text strings"),
|
||||
(
|
||||
(310, 319),
|
||||
"Arbitrary binary chunks with same representation and limits as 1004 "
|
||||
"group codes: hexadecimal strings of up to 254 characters represent data "
|
||||
"chunks of up to 127 bytes",
|
||||
),
|
||||
(
|
||||
(320, 329),
|
||||
"Arbitrary object handles; handle values that are taken 'as is'. They "
|
||||
"are not translated during INSERT and XREF operations",
|
||||
),
|
||||
(
|
||||
(330, 339),
|
||||
"Soft-pointer handle; arbitrary soft pointers to other objects within "
|
||||
"same DXF file or drawing. Translated during INSERT and XREF operations",
|
||||
),
|
||||
(
|
||||
(340, 349),
|
||||
"Hard-pointer handle; arbitrary hard pointers to other objects within "
|
||||
"same DXF file or drawing. Translated during INSERT and XREF operations",
|
||||
),
|
||||
(
|
||||
(350, 359),
|
||||
"Soft-owner handle; arbitrary soft ownership links to other objects "
|
||||
"within same DXF file or drawing. Translated during INSERT and XREF "
|
||||
"operations",
|
||||
),
|
||||
(
|
||||
(360, 369),
|
||||
"Hard-owner handle; arbitrary hard ownership links to other objects within "
|
||||
"same DXF file or drawing. Translated during INSERT and XREF operations",
|
||||
),
|
||||
(
|
||||
(370, 379),
|
||||
"Lineweight enum value (AcDb::LineWeight). Stored and moved around as a 16-bit integer. "
|
||||
"Custom non-entity objects may use the full range, but entity classes only use 371-379 DXF "
|
||||
"group codes in their representation, because AutoCAD and AutoLISP both always assume a 370 "
|
||||
"group code is the entity's lineweight. This allows 370 to behave like other 'common' entity fields",
|
||||
),
|
||||
(
|
||||
(380, 389),
|
||||
"PlotStyleName type enum (AcDb::PlotStyleNameType). Stored and moved around as a 16-bit integer. "
|
||||
"Custom non-entity objects may use the full range, but entity classes only use 381-389 "
|
||||
"DXF group codes in their representation, for the same reason as the lineweight range",
|
||||
),
|
||||
(
|
||||
(390, 399),
|
||||
"String representing handle value of the PlotStyleName object, basically a hard pointer, but has "
|
||||
"a different range to make backward compatibility easier to deal with. Stored and moved around "
|
||||
"as an object ID (a handle in DXF files) and a special type in AutoLISP. Custom non-entity objects "
|
||||
"may use the full range, but entity classes only use 391-399 DXF group codes in their representation, "
|
||||
"for the same reason as the lineweight range",
|
||||
),
|
||||
((400, 409), "16-bit integers"),
|
||||
((410, 419), "String"),
|
||||
(
|
||||
(420, 427),
|
||||
"32-bit integer value. When used with True Color; a 32-bit integer representing a 24-bit color value. "
|
||||
"The high-order byte (8 bits) is 0, the low-order byte an unsigned char holding the Blue value (0-255), "
|
||||
"then the Green value, and the next-to-high order byte is the Red Value. Converting this integer value to "
|
||||
"hexadecimal yields the following bit mask: 0x00RRGGBB. "
|
||||
"For example, a true color with Red==200, Green==100 and Blue==50 is 0x00C86432, and in DXF, in decimal, 13132850",
|
||||
),
|
||||
(
|
||||
(430, 437),
|
||||
"String; when used for True Color, a string representing the name of the color",
|
||||
),
|
||||
(
|
||||
(440, 447),
|
||||
"32-bit integer value. When used for True Color, the transparency value",
|
||||
),
|
||||
((450, 459), "Long"),
|
||||
((460, 469), "Double-precision floating-point value"),
|
||||
((470, 479), "String"),
|
||||
(
|
||||
(480, 481),
|
||||
"Hard-pointer handle; arbitrary hard pointers to other objects within same DXF file or drawing. "
|
||||
"Translated during INSERT and XREF operations",
|
||||
),
|
||||
(
|
||||
999,
|
||||
"DXF: The 999 group code indicates that the line following it is a comment string. SAVEAS does "
|
||||
"not include such groups in a DXF output file, but OPEN honors them and ignores the comments. "
|
||||
"You can use the 999 group to include comments in a DXF file that you have edited",
|
||||
),
|
||||
(1000, "ASCII string (up to 255 bytes long) in extended data"),
|
||||
(
|
||||
1001,
|
||||
"Registered application name (ASCII string up to 31 bytes long) for extended data",
|
||||
),
|
||||
(1002, "Extended data control string ('{' or '}')"),
|
||||
(1003, "Extended data layer name"),
|
||||
(1004, "Chunk of bytes (up to 127 bytes long) in extended data"),
|
||||
(
|
||||
1005,
|
||||
"Entity handle in extended data; text string of up to 16 hexadecimal digits",
|
||||
),
|
||||
(
|
||||
1010,
|
||||
"A point in extended data; DXF: X value (followed by 1020 and 1030 groups); APP: 3D point",
|
||||
),
|
||||
(1020, "DXF: Y values of a point"),
|
||||
(1030, "DXF: Z values of a point"),
|
||||
(
|
||||
1011,
|
||||
"A 3D world space position in extended data "
|
||||
"DXF: X value (followed by 1021 and 1031 groups) "
|
||||
"APP: 3D point",
|
||||
),
|
||||
(1021, "DXF: Y value of a world space position"),
|
||||
(1031, "DXF: Z value of a world space position"),
|
||||
(
|
||||
1012,
|
||||
"A 3D world space displacement in extended data "
|
||||
"DXF: X value (followed by 1022 and 1032 groups) "
|
||||
"APP: 3D vector",
|
||||
),
|
||||
(1022, "DXF: Y value of a world space displacement"),
|
||||
(1032, "DXF: Z value of a world space displacement"),
|
||||
(
|
||||
1013,
|
||||
"A 3D world space direction in extended data "
|
||||
"DXF: X value (followed by 1022 and 1032 groups) "
|
||||
"APP: 3D vector",
|
||||
),
|
||||
(1023, "DXF: Y value of a world space direction"),
|
||||
(1033, "DXF: Z value of a world space direction"),
|
||||
(1040, "Extended data double-precision floating-point value"),
|
||||
(1041, "Extended data distance value"),
|
||||
(1042, "Extended data scale factor"),
|
||||
(1070, "Extended data 16-bit signed integer"),
|
||||
(1071, "Extended data 32-bit signed long"),
|
||||
]
|
||||
|
||||
|
||||
def build_group_code_tooltip_dict() -> dict[int, str]:
|
||||
tooltips = dict()
|
||||
for code, tooltip in GROUP_CODE_TOOLTIPS:
|
||||
tooltip = "\n".join(textwrap.wrap(tooltip, width=80))
|
||||
if isinstance(code, int):
|
||||
tooltips[code] = tooltip
|
||||
elif isinstance(code, tuple):
|
||||
s, e = code
|
||||
for group_code in range(s, e + 1):
|
||||
tooltips[group_code] = tooltip
|
||||
else:
|
||||
raise ValueError(type(code))
|
||||
|
||||
return tooltips
|
||||
|
||||
|
||||
GROUP_CODE_TOOLTIPS_DICT = build_group_code_tooltip_dict()
|
||||
@@ -0,0 +1,117 @@
|
||||
# Autodesk DXF 2018 Reference
|
||||
link_tpl = "https://help.autodesk.com/view/OARX/2018/ENU/?guid={guid}"
|
||||
# Autodesk DXF 2014 Reference
|
||||
# link_tpl = 'https://docs.autodesk.com/ACD/2014/ENU/files/{guid}.htm'
|
||||
main_index_guid = "GUID-235B22E0-A567-4CF6-92D3-38A2306D73F3" # main index
|
||||
|
||||
reference_guids = {
|
||||
"HEADER": "GUID-EA9CDD11-19D1-4EBC-9F56-979ACF679E3C",
|
||||
"CLASSES": "GUID-6160F1F1-2805-4C69-8077-CA1AEB6B1005",
|
||||
"TABLES": "GUID-A9FD9590-C97B-4E41-9F26-BD82C34A4F9F",
|
||||
"BLOCKS": "GUID-1D14A213-5E4D-4EA6-A6B5-8709EB925D01",
|
||||
"ENTITIES": "GUID-7D07C886-FD1D-4A0C-A7AB-B4D21F18E484",
|
||||
"OBJECTS": "GUID-2D71EE99-A6BE-4060-9B43-808CF1E201C6",
|
||||
"THUMBNAILIMAGE": "GUID-792F79DC-0D5D-43B5-AB0E-212E0EDF6BAE",
|
||||
"APPIDS": "GUID-6E3140E9-E560-4C77-904E-480382F0553E",
|
||||
"APPID": "GUID-6E3140E9-E560-4C77-904E-480382F0553E",
|
||||
"BLOCK_RECORDS": "GUID-A1FD1934-7EF5-4D35-A4B0-F8AE54A9A20A",
|
||||
"BLOCK_RECORD": "GUID-A1FD1934-7EF5-4D35-A4B0-F8AE54A9A20A",
|
||||
"DIMSTYLES": "GUID-F2FAD36F-0CE3-4943-9DAD-A9BCD2AE81DA",
|
||||
"DIMSTYLE": "GUID-F2FAD36F-0CE3-4943-9DAD-A9BCD2AE81DA",
|
||||
"LAYERS": "GUID-D94802B0-8BE8-4AC9-8054-17197688AFDB",
|
||||
"LAYER": "GUID-D94802B0-8BE8-4AC9-8054-17197688AFDB",
|
||||
"LINETYPES": "GUID-F57A316C-94A2-416C-8280-191E34B182AC",
|
||||
"LTYPE": "GUID-F57A316C-94A2-416C-8280-191E34B182AC",
|
||||
"STYLES": "GUID-EF68AF7C-13EF-45A1-8175-ED6CE66C8FC9",
|
||||
"STYLE": "GUID-EF68AF7C-13EF-45A1-8175-ED6CE66C8FC9",
|
||||
"UCS": "GUID-1906E8A7-3393-4BF9-BD27-F9AE4352FB8B",
|
||||
"VIEWS": "GUID-CF3094AB-ECA9-43C1-8075-7791AC84F97C",
|
||||
"VIEW": "GUID-CF3094AB-ECA9-43C1-8075-7791AC84F97C",
|
||||
"VIEWPORTS": "GUID-8CE7CC87-27BD-4490-89DA-C21F516415A9",
|
||||
"VPORT": "GUID-8CE7CC87-27BD-4490-89DA-C21F516415A9",
|
||||
"BLOCK": "GUID-66D32572-005A-4E23-8B8B-8726E8C14302",
|
||||
"ENDBLK": "GUID-27F7CC8A-E340-4C7F-A77F-5AF139AD502D",
|
||||
"3DFACE": "GUID-747865D5-51F0-45F2-BEFE-9572DBC5B151",
|
||||
"3DSOLID": "GUID-19AB1C40-0BE0-4F32-BCAB-04B37044A0D3",
|
||||
"ACAD_PROXY_ENTITY": "GUID-89A690F9-E859-4D57-89EA-750F3FB76C6B",
|
||||
"ARC": "GUID-0B14D8F1-0EBA-44BF-9108-57D8CE614BC8",
|
||||
"ATTDEF": "GUID-F0EA099B-6F88-4BCC-BEC7-247BA64838A4",
|
||||
"ATTRIB": "GUID-7DD8B495-C3F8-48CD-A766-14F9D7D0DD9B",
|
||||
"BODY": "GUID-7FB91514-56FF-4487-850E-CF1047999E77",
|
||||
"CIRCLE": "GUID-8663262B-222C-414D-B133-4A8506A27C18",
|
||||
"DIMENSION": "GUID-239A1BDD-7459-4BB9-8DD7-08EC79BF1EB0",
|
||||
"ELLIPSE": "GUID-107CB04F-AD4D-4D2F-8EC9-AC90888063AB",
|
||||
"HATCH": "GUID-C6C71CED-CE0F-4184-82A5-07AD6241F15B",
|
||||
"HELIX": "GUID-76DB3ABF-3C8C-47D1-8AFB-72942D9AE1FF",
|
||||
"IMAGE": "GUID-3A2FF847-BE14-4AC5-9BD4-BD3DCAEF2281",
|
||||
"INSERT": "GUID-28FA4CFB-9D5E-4880-9F11-36C97578252F",
|
||||
"LEADER": "GUID-396B2369-F89F-47D7-8223-8B7FB794F9F3",
|
||||
"LIGHT": "GUID-1A23DB42-6A92-48E9-9EB2-A7856A479930",
|
||||
"LINE": "GUID-FCEF5726-53AE-4C43-B4EA-C84EB8686A66",
|
||||
"LWPOLYLINE": "GUID-748FC305-F3F2-4F74-825A-61F04D757A50",
|
||||
"MESH": "GUID-4B9ADA67-87C8-4673-A579-6E4C76FF7025",
|
||||
"MLINE": "GUID-590E8AE3-C6D9-4641-8485-D7B3693E432C",
|
||||
"MLEADERSTYLE": "GUID-0E489B69-17A4-4439-8505-9DCE032100B4",
|
||||
"MLEADER": "GUID-72D20B8C-0F5E-4993-BEB7-0FCF94F32BE0",
|
||||
"MULTILEADER": "GUID-72D20B8C-0F5E-4993-BEB7-0FCF94F32BE0",
|
||||
"MTEXT": "GUID-5E5DB93B-F8D3-4433-ADF7-E92E250D2BAB",
|
||||
"OLEFRAME": "GUID-4A10EF68-35A3-4961-8B15-1222ECE5E8C6",
|
||||
"OLE2FRAME": "GUID-77747CE6-82C6-4452-97ED-4CEEB38BE960",
|
||||
"POINT": "GUID-9C6AD32D-769D-4213-85A4-CA9CCB5C5317",
|
||||
"POLYLINE": "GUID-ABF6B778-BE20-4B49-9B58-A94E64CEFFF3",
|
||||
"RAY": "GUID-638B9F01-5D86-408E-A2DE-FA5D6ADBD415",
|
||||
"REGION": "GUID-644BF0F0-FD79-4C5E-AD5A-0053FCC5A5A4",
|
||||
"SECTION": "GUID-8B60CBAB-B226-4A5F-ABB1-46FD8AABB928",
|
||||
"SEQEND": "GUID-FD4FAA74-1F6D-45F6-B132-BF0C4BE6CC3B",
|
||||
"SHAPE": "GUID-0988D755-9AAB-4D6C-8E26-EC636F507F2C",
|
||||
"SOLID": "GUID-E0C5F04E-D0C5-48F5-AC09-32733E8848F2",
|
||||
"SPLINE": "GUID-E1F884F8-AA90-4864-A215-3182D47A9C74",
|
||||
"SUN": "GUID-BB191D89-9302-45E4-9904-108AB418FAE1",
|
||||
"SURFACE": "GUID-BB62483A-89C3-47C4-80E5-EA3F08979863",
|
||||
"TABLE": "GUID-D8CCD2F0-18A3-42BB-A64D-539114A07DA0",
|
||||
"TEXT": "GUID-62E5383D-8A14-47B4-BFC4-35824CAE8363",
|
||||
"TOLERANCE": "GUID-ADFCED35-B312-4996-B4C1-61C53757B3FD",
|
||||
"TRACE": "GUID-EA6FBCA8-1AD6-4FB2-B149-770313E93511",
|
||||
"UNDERLAY": "GUID-3EC8FBCC-A85A-4B0B-93CD-C6C785959077",
|
||||
"VERTEX": "GUID-0741E831-599E-4CBF-91E1-8ADBCFD6556D",
|
||||
"VIEWPORT": "GUID-2602B0FB-02E4-4B9A-B03C-B1D904753D34",
|
||||
"WIPEOUT": "GUID-2229F9C4-3C80-4C67-9EDA-45ED684808DC",
|
||||
"XLINE": "GUID-55080553-34B6-40AA-9EE2-3F3A3A2A5C0A",
|
||||
"ACAD_PROXY_OBJECT": "GUID-F59F0EC3-D34D-4C1A-91AC-7FDA569EF016",
|
||||
"ACDBDICTIONARYWDFLT": "GUID-A6605C05-1CF4-42A4-95EC-42190B2424EE",
|
||||
"ACDBPLACEHOLDER": "GUID-3BC75FF1-6139-49F4-AEBB-AE2AB4F437E4",
|
||||
"DATATABLE": "GUID-D09D0650-B926-40DD-A2F2-4FD5BDDFC330",
|
||||
"DICTIONARY": "GUID-40B92C63-26F0-485B-A9C2-B349099B26D0",
|
||||
"DICTIONARYVAR": "GUID-D305303C-F9CE-4714-9C92-607BFDA891B4",
|
||||
"DIMASSOC": "GUID-C0B96256-A911-4B4D-85E6-EB4AF2C91E27",
|
||||
"FIELD": "GUID-51B921F2-16CA-4948-AC75-196198DD1796",
|
||||
"GEODATA": "GUID-104FE0E2-4801-4AC8-B92C-1DDF5AC7AB64",
|
||||
"GROUP": "GUID-5F1372C4-37C8-4056-9303-EE1715F58E67",
|
||||
"IDBUFFER": "GUID-7A243F2B-72D8-4C48-A29A-3F251B86D03F",
|
||||
"IMAGEDEF": "GUID-EFE5319F-A71A-4612-9431-42B6C7C3941F",
|
||||
"IMAGEDEF_REACTOR": "GUID-46C12333-1EDA-4619-B2C9-D7D2607110C8",
|
||||
"LAYER_INDEX": "GUID-17560B05-31B9-44A5-BA92-E92C799398C0",
|
||||
"LAYER_FILTER": "GUID-3B44DCFD-FA96-482B-8468-37B3C5B5F289",
|
||||
"LAYOUT": "GUID-433D25BF-655D-4697-834E-C666EDFD956D",
|
||||
"LIGHTLIST": "GUID-C4E7FFF8-C3ED-43DD-854D-304F87FFCF06",
|
||||
"MATERIAL": "GUID-E540C5BB-E166-44FA-B36C-5C739878B272",
|
||||
"MLINESTYLE": "GUID-3EC12E5B-F5F6-484D-880F-D69EBE186D79",
|
||||
"OBJECT_PTR": "GUID-6D6885E2-281C-410A-92FB-8F6A7F54C9DF",
|
||||
"PLOTSETTINGS": "GUID-1113675E-AB07-4567-801A-310CDE0D56E9",
|
||||
"RASTERVARIABLES": "GUID-DDCC21A4-822A-469B-9954-1E1EC4F6DF82",
|
||||
"SPATIAL_INDEX": "GUID-CD1E44DA-CDBA-4AA7-B08E-C53F71648984",
|
||||
"SPATIAL_FILTER": "GUID-34F179D8-2030-47E4-8D49-F87B6538A05A",
|
||||
"SORTENTSTABLE": "GUID-462F4378-F850-4E89-90F2-3C1880F55779",
|
||||
"SUNSTUDY": "GUID-1C7C073F-4CFD-4939-97D9-7AB0C1E163A3",
|
||||
"TABLESTYLE": "GUID-0DBCA057-9F6C-4DEB-A66F-8A9B3C62FB1A",
|
||||
"UNDERLAYDEFINITION": "GUID-A4FF15D3-F745-4E1F-94D4-1DC3DF297B0F",
|
||||
"VISUALSTYLE": "GUID-8A8BF2C4-FC56-44EC-A8C4-A60CE33A530C",
|
||||
"VBA_PROJECT": "GUID-F247DB75-5C4D-4944-8C20-1567480221F4",
|
||||
"WIPEOUTVARIABLES": "GUID-CD28B95F-483C-4080-82A6-420606F88356",
|
||||
"XRECORD": "GUID-24668FAF-AE03-41AE-AFA4-276C3692827F",
|
||||
}
|
||||
|
||||
|
||||
def get_reference_link(name: str) -> str:
|
||||
guid = reference_guids.get(name, main_index_guid)
|
||||
return link_tpl.format(guid=guid)
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from ezdxf.lldxf.tags import Tags
|
||||
from ezdxf.lldxf.types import (
|
||||
DXFTag,
|
||||
DXFVertex,
|
||||
POINT_CODES,
|
||||
TYPE_TABLE,
|
||||
)
|
||||
|
||||
|
||||
def tag_compiler(tags: Tags) -> Iterable[DXFTag]:
|
||||
"""Special tag compiler for the DXF browser.
|
||||
|
||||
This compiler should never fail and always return printable tags:
|
||||
|
||||
- invalid point coordinates are returned as float("nan")
|
||||
- invalid ints are returned as string
|
||||
- invalid floats are returned as string
|
||||
|
||||
"""
|
||||
|
||||
def to_float(v: str) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return float("NaN")
|
||||
|
||||
count = len(tags)
|
||||
index = 0
|
||||
while index < count:
|
||||
code, value = tags[index]
|
||||
if code in POINT_CODES:
|
||||
try:
|
||||
y_code, y_value = tags[index + 1]
|
||||
except IndexError: # x-coord as last tag
|
||||
yield DXFTag(code, to_float(value))
|
||||
return
|
||||
|
||||
if y_code != code + 10: # not an y-coord?
|
||||
yield DXFTag(code, to_float(value)) # x-coord as single tag
|
||||
index += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
z_code, z_value = tags[index + 2]
|
||||
except IndexError: # no z-coord exist
|
||||
z_code = 0
|
||||
z_value = 0
|
||||
point: Sequence[float]
|
||||
if z_code == code + 20: # is a z-coord?
|
||||
point = (to_float(value), to_float(y_value), to_float(z_value))
|
||||
index += 3
|
||||
else: # a valid 2d point(x, y)
|
||||
point = (to_float(value), to_float(y_value))
|
||||
index += 2
|
||||
yield DXFVertex(code, point)
|
||||
else: # a single tag
|
||||
try:
|
||||
if code == 0:
|
||||
value = value.strip()
|
||||
yield DXFTag(code, TYPE_TABLE.get(code, str)(value))
|
||||
except ValueError:
|
||||
yield DXFTag(code, str(value)) # just as string
|
||||
index += 1
|
||||
|
||||
|
||||
def compile_tags(tags: Tags) -> Tags:
|
||||
return Tags(tag_compiler(tags))
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from ezdxf.addons.xqt import QTableView, QTreeView, QModelIndex
|
||||
from ezdxf.lldxf.tags import Tags
|
||||
|
||||
|
||||
class StructureTree(QTreeView):
|
||||
def set_structure(self, model):
|
||||
self.setModel(model)
|
||||
self.expand(model.index(0, 0, QModelIndex()))
|
||||
self.setHeaderHidden(True)
|
||||
|
||||
def expand_to_entity(self, entity: Tags):
|
||||
model = self.model()
|
||||
index = model.index_of_entity(entity) # type: ignore
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
|
||||
class DXFTagsTable(QTableView):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
col_header = self.horizontalHeader()
|
||||
col_header.setStretchLastSection(True)
|
||||
row_header = self.verticalHeader()
|
||||
row_header.setDefaultSectionSize(24) # default row height in pixels
|
||||
self.setSelectionBehavior(QTableView.SelectRows)
|
||||
|
||||
def first_selected_row(self) -> int:
|
||||
first_row: int = 0
|
||||
selection = self.selectedIndexes()
|
||||
if selection:
|
||||
first_row = selection[0].row()
|
||||
return first_row
|
||||
|
||||
def selected_rows(self) -> list[int]:
|
||||
rows: set[int] = set()
|
||||
selection = self.selectedIndexes()
|
||||
for item in selection:
|
||||
rows.add(item.row())
|
||||
return sorted(rows)
|
||||
@@ -0,0 +1,766 @@
|
||||
# Copyright (c) 2010-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
"""Dimension lines as composite entities build up by DXF primitives.
|
||||
|
||||
This add-on exist just for an easy transition from `dxfwrite` to `ezdxf`.
|
||||
|
||||
Classes
|
||||
-------
|
||||
|
||||
- LinearDimension
|
||||
- AngularDimension
|
||||
- ArcDimension
|
||||
- RadiusDimension
|
||||
- DimStyle
|
||||
|
||||
This code was written long before I had any understanding of the DIMENSION
|
||||
entity and therefore, the classes have completely different implementations and
|
||||
styling features than the dimensions based on the DIMENSION entity .
|
||||
|
||||
.. warning::
|
||||
|
||||
Try to not use these classes beside porting `dxfwrite` code to `ezdxf`
|
||||
and even for that case the usage of the regular DIMENSION entity is to
|
||||
prefer because this module will not get much maintenance and may be removed
|
||||
in the future.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Any, TYPE_CHECKING, Iterable, Optional
|
||||
from math import radians, degrees, pi
|
||||
from abc import abstractmethod
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.math import Vec3, Vec2, distance, lerp, ConstructionRay, UVec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.eztypes import GenericLayoutType
|
||||
|
||||
DIMENSIONS_MIN_DISTANCE = 0.05
|
||||
DIMENSIONS_FLOATINGPOINT = "."
|
||||
|
||||
ANGLE_DEG = 180.0 / pi
|
||||
ANGLE_GRAD = 200.0 / pi
|
||||
ANGLE_RAD = 1.0
|
||||
|
||||
|
||||
class DimStyle(dict):
|
||||
"""DimStyle parameter struct, a dumb object just to store values
|
||||
"""
|
||||
|
||||
default_values = [
|
||||
# tick block name, use setup to generate default blocks <dimblk> <dimblk1> <dimblk2>
|
||||
("tick", "DIMTICK_ARCH"),
|
||||
# scale factor for ticks-block <dimtsz> <dimasz>
|
||||
("tickfactor", 1.0),
|
||||
# tick2x means tick is drawn only for one side, insert tick a second
|
||||
# time rotated about 180 degree, but only one time at the dimension line
|
||||
# ends, this is useful for arrow-like ticks. hint: set dimlineext to 0. <none>
|
||||
("tick2x", False),
|
||||
# dimension value scale factor, value = drawing-units * scale <dimlfac>
|
||||
("scale", 100.0),
|
||||
# round dimension value to roundval fractional digits <dimdec>
|
||||
("roundval", 0),
|
||||
# round dimension value to half units, round 0.4, 0.6 to 0.5 <dimrnd>
|
||||
("roundhalf", False),
|
||||
# dimension value text color <dimclrt>
|
||||
("textcolor", 7),
|
||||
# dimension value text height <dimtxt>
|
||||
("height", 0.5),
|
||||
# dimension text prefix and suffix like 'x=' ... ' cm' <dimpost>
|
||||
("prefix", ""),
|
||||
("suffix", ""),
|
||||
# dimension value text style <dimtxsty>
|
||||
("style", "OpenSansCondensed-Light"),
|
||||
# default layer for whole dimension object
|
||||
("layer", "DIMENSIONS"),
|
||||
# dimension line color index (0 from layer) <dimclrd>
|
||||
("dimlinecolor", 7),
|
||||
# dimension line extensions (in dimline direction, left and right) <dimdle>
|
||||
("dimlineext", 0.3),
|
||||
# draw dimension value text `textabove` drawing-units above the
|
||||
# dimension line <dimgap>
|
||||
("textabove", 0.2),
|
||||
# switch extension line False=off, True=on <dimse1> <dimse2>
|
||||
("dimextline", True),
|
||||
# dimension extension line color index (0 from layer) <dimclre>
|
||||
("dimextlinecolor", 5),
|
||||
# gap between measure target point and end of extension line <dimexo>
|
||||
("dimextlinegap", 0.3),
|
||||
]
|
||||
|
||||
def __init__(self, name: str, **kwargs):
|
||||
super().__init__(DimStyle.default_values)
|
||||
# dimstyle name
|
||||
self["name"] = name
|
||||
self.update(kwargs)
|
||||
|
||||
def __getattr__(self, attr: str) -> Any:
|
||||
return self[attr]
|
||||
|
||||
def __setattr__(self, attr: str, value: Any) -> None:
|
||||
self[attr] = value
|
||||
|
||||
|
||||
class DimStyles:
|
||||
"""
|
||||
DimStyle container
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._styles: dict[str, DimStyle] = {}
|
||||
self.default = DimStyle("Default")
|
||||
|
||||
self.new(
|
||||
"angle.deg",
|
||||
scale=ANGLE_DEG,
|
||||
suffix=str("°"),
|
||||
roundval=0,
|
||||
tick="DIMTICK_RADIUS",
|
||||
tick2x=True,
|
||||
dimlineext=0.0,
|
||||
dimextline=False,
|
||||
)
|
||||
self.new(
|
||||
"angle.grad",
|
||||
scale=ANGLE_GRAD,
|
||||
suffix="gon",
|
||||
roundval=0,
|
||||
tick="DIMTICK_RADIUS",
|
||||
tick2x=True,
|
||||
dimlineext=0.0,
|
||||
dimextline=False,
|
||||
)
|
||||
self.new(
|
||||
"angle.rad",
|
||||
scale=ANGLE_RAD,
|
||||
suffix="rad",
|
||||
roundval=3,
|
||||
tick="DIMTICK_RADIUS",
|
||||
tick2x=True,
|
||||
dimlineext=0.0,
|
||||
dimextline=False,
|
||||
)
|
||||
|
||||
def get(self, name: str) -> DimStyle:
|
||||
"""
|
||||
Get DimStyle() object by name.
|
||||
"""
|
||||
return self._styles.get(name, self.default)
|
||||
|
||||
def new(self, name: str, **kwargs) -> DimStyle:
|
||||
"""
|
||||
Create a new dimstyle
|
||||
"""
|
||||
style = DimStyle(name, **kwargs)
|
||||
self._styles[name] = style
|
||||
return style
|
||||
|
||||
@staticmethod
|
||||
def setup(drawing: "Drawing"):
|
||||
"""
|
||||
Insert necessary definitions into drawing:
|
||||
|
||||
ticks: DIMTICK_ARCH, DIMTICK_DOT, DIMTICK_ARROW
|
||||
"""
|
||||
# default pen assignment:
|
||||
# 1 : 1.40mm - red
|
||||
# 2 : 0.35mm - yellow
|
||||
# 3 : 0.70mm - green
|
||||
# 4 : 0.50mm - cyan
|
||||
# 5 : 0.13mm - blue
|
||||
# 6 : 1.00mm - magenta
|
||||
# 7 : 0.25mm - white/black
|
||||
# 8, 9 : 2.00mm
|
||||
# >=10 : 1.40mm
|
||||
|
||||
dimcolor = {
|
||||
"color": dimstyles.default.dimextlinecolor,
|
||||
"layer": "BYBLOCK",
|
||||
}
|
||||
color4 = {"color": 4, "layer": "BYBLOCK"}
|
||||
color7 = {"color": 7, "layer": "BYBLOCK"}
|
||||
|
||||
block = drawing.blocks.new("DIMTICK_ARCH")
|
||||
block.add_line(start=(0.0, +0.5), end=(0.0, -0.5), dxfattribs=dimcolor)
|
||||
block.add_line(start=(-0.2, -0.2), end=(0.2, +0.2), dxfattribs=color4)
|
||||
|
||||
block = drawing.blocks.new("DIMTICK_DOT")
|
||||
block.add_line(start=(0.0, 0.5), end=(0.0, -0.5), dxfattribs=dimcolor)
|
||||
block.add_circle(center=(0, 0), radius=0.1, dxfattribs=color4)
|
||||
|
||||
block = drawing.blocks.new("DIMTICK_ARROW")
|
||||
|
||||
block.add_line(start=(0.0, 0.5), end=(0.0, -0.50), dxfattribs=dimcolor)
|
||||
block.add_solid([(0, 0), (0.3, 0.05), (0.3, -0.05)], dxfattribs=color7)
|
||||
|
||||
block = drawing.blocks.new("DIMTICK_RADIUS")
|
||||
block.add_solid(
|
||||
[(0, 0), (0.3, 0.05), (0.25, 0.0), (0.3, -0.05)], dxfattribs=color7
|
||||
)
|
||||
|
||||
|
||||
dimstyles = DimStyles() # use this factory to create new dimstyles
|
||||
|
||||
|
||||
class _DimensionBase:
|
||||
"""
|
||||
Abstract base class for dimension lines.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, dimstyle: str, layer: str, roundval: int):
|
||||
self.dimstyle = dimstyles.get(dimstyle)
|
||||
self.layer = layer
|
||||
self.roundval = roundval
|
||||
|
||||
def prop(self, property_name: str) -> Any:
|
||||
"""
|
||||
Get dimension line properties by `property_name` with the possibility to override several properties.
|
||||
"""
|
||||
if property_name == "layer":
|
||||
return self.layer if self.layer is not None else self.dimstyle.layer
|
||||
elif property_name == "roundval":
|
||||
return (
|
||||
self.roundval
|
||||
if self.roundval is not None
|
||||
else self.dimstyle.roundval
|
||||
)
|
||||
else: # pass through self.dimstyle object DimStyle()
|
||||
return self.dimstyle[property_name]
|
||||
|
||||
def format_dimtext(self, dimvalue: float) -> str:
|
||||
"""
|
||||
Format the dimension text.
|
||||
"""
|
||||
dimtextfmt = "%." + str(self.prop("roundval")) + "f"
|
||||
dimtext = dimtextfmt % dimvalue
|
||||
if DIMENSIONS_FLOATINGPOINT in dimtext:
|
||||
# remove successional zeros
|
||||
dimtext.rstrip("0")
|
||||
# remove floating point as last char
|
||||
dimtext.rstrip(DIMENSIONS_FLOATINGPOINT)
|
||||
return self.prop("prefix") + dimtext + self.prop("suffix")
|
||||
|
||||
@abstractmethod
|
||||
def render(self, layout: "GenericLayoutType"):
|
||||
pass
|
||||
|
||||
|
||||
class LinearDimension(_DimensionBase):
|
||||
"""
|
||||
Simple straight dimension line with two or more measure points, build with basic DXF entities. This is NOT a dxf
|
||||
dimension entity. And This is a 2D element, so all z-values will be ignored!
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pos: UVec,
|
||||
measure_points: Iterable[UVec],
|
||||
angle: float = 0.0,
|
||||
dimstyle: str = "Default",
|
||||
layer: Optional[str] = None,
|
||||
roundval: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
LinearDimension Constructor.
|
||||
|
||||
Args:
|
||||
pos: location as (x, y) tuple of dimension line, line goes through this point
|
||||
measure_points: list of points as (x, y) tuples to dimension (two or more)
|
||||
angle: angle (in degree) of dimension line
|
||||
dimstyle: dimstyle name, 'Default' - style is the default value
|
||||
layer: dimension line layer, override the default value of dimstyle
|
||||
roundval: count of decimal places
|
||||
|
||||
"""
|
||||
super().__init__(dimstyle, layer, roundval) # type: ignore
|
||||
self.angle = angle
|
||||
self.measure_points = list(measure_points)
|
||||
self.text_override = [""] * self.section_count
|
||||
self.dimlinepos = Vec3(pos)
|
||||
self.layout = None
|
||||
|
||||
def set_text(self, section: int, text: str) -> None:
|
||||
"""
|
||||
Set and override the text of the dimension text for the given dimension line section.
|
||||
"""
|
||||
self.text_override[section] = text
|
||||
|
||||
def _setup(self) -> None:
|
||||
"""
|
||||
Calc setup values and determines the point order of the dimension line points.
|
||||
"""
|
||||
self.measure_points = [Vec3(point) for point in self.measure_points]
|
||||
dimlineray = ConstructionRay(self.dimlinepos, angle=radians(self.angle))
|
||||
self.dimline_points = [
|
||||
self._get_point_on_dimline(point, dimlineray)
|
||||
for point in self.measure_points
|
||||
]
|
||||
self.point_order = self._indices_of_sorted_points(self.dimline_points)
|
||||
self._build_vectors()
|
||||
|
||||
def _get_dimline_point(self, index: int) -> UVec:
|
||||
"""
|
||||
Get point on the dimension line, index runs left to right.
|
||||
"""
|
||||
return self.dimline_points[self.point_order[index]]
|
||||
|
||||
def _get_section_points(self, section: int) -> tuple[Vec3, Vec3]:
|
||||
"""
|
||||
Get start and end point on the dimension line of dimension section.
|
||||
"""
|
||||
return self._get_dimline_point(section), self._get_dimline_point(
|
||||
section + 1
|
||||
)
|
||||
|
||||
def _get_dimline_bounds(self) -> tuple[Vec3, Vec3]:
|
||||
"""
|
||||
Get the first and the last point of dimension line.
|
||||
"""
|
||||
return self._get_dimline_point(0), self._get_dimline_point(-1)
|
||||
|
||||
@property
|
||||
def section_count(self) -> int:
|
||||
"""count of dimline sections"""
|
||||
return len(self.measure_points) - 1
|
||||
|
||||
@property
|
||||
def point_count(self) -> int:
|
||||
"""count of dimline points"""
|
||||
return len(self.measure_points)
|
||||
|
||||
def render(self, layout: "GenericLayoutType") -> None:
|
||||
"""build dimension line object with basic dxf entities"""
|
||||
self._setup()
|
||||
self._draw_dimline(layout)
|
||||
if self.prop("dimextline"):
|
||||
self._draw_extension_lines(layout)
|
||||
self._draw_text(layout)
|
||||
self._draw_ticks(layout)
|
||||
|
||||
@staticmethod
|
||||
def _indices_of_sorted_points(points: Iterable[UVec]) -> list[int]:
|
||||
"""get indices of points, for points sorted by x, y values"""
|
||||
indexed_points = [(point, idx) for idx, point in enumerate(points)]
|
||||
indexed_points.sort()
|
||||
return [idx for point, idx in indexed_points]
|
||||
|
||||
def _build_vectors(self) -> None:
|
||||
"""build unit vectors, parallel and normal to dimension line"""
|
||||
point1, point2 = self._get_dimline_bounds()
|
||||
self.parallel_vector = (Vec3(point2) - Vec3(point1)).normalize()
|
||||
self.normal_vector = self.parallel_vector.orthogonal()
|
||||
|
||||
@staticmethod
|
||||
def _get_point_on_dimline(point: UVec, dimray: ConstructionRay) -> Vec3:
|
||||
"""get the measure target point projection on the dimension line"""
|
||||
return dimray.intersect(dimray.orthogonal(point))
|
||||
|
||||
def _draw_dimline(self, layout: "GenericLayoutType") -> None:
|
||||
"""build dimension line entity"""
|
||||
start_point, end_point = self._get_dimline_bounds()
|
||||
|
||||
dimlineext = self.prop("dimlineext")
|
||||
if dimlineext > 0:
|
||||
start_point = start_point - (self.parallel_vector * dimlineext)
|
||||
end_point = end_point + (self.parallel_vector * dimlineext)
|
||||
|
||||
attribs = {
|
||||
"color": self.prop("dimlinecolor"),
|
||||
"layer": self.prop("layer"),
|
||||
}
|
||||
layout.add_line(
|
||||
start=start_point,
|
||||
end=end_point,
|
||||
dxfattribs=attribs,
|
||||
)
|
||||
|
||||
def _draw_extension_lines(self, layout: "GenericLayoutType") -> None:
|
||||
"""build the extension lines entities"""
|
||||
dimextlinegap = self.prop("dimextlinegap")
|
||||
attribs = {
|
||||
"color": self.prop("dimlinecolor"),
|
||||
"layer": self.prop("layer"),
|
||||
}
|
||||
|
||||
for dimline_point, target_point in zip(
|
||||
self.dimline_points, self.measure_points
|
||||
):
|
||||
if distance(dimline_point, target_point) > max(
|
||||
dimextlinegap, DIMENSIONS_MIN_DISTANCE
|
||||
):
|
||||
direction_vector = (target_point - dimline_point).normalize()
|
||||
target_point = target_point - (direction_vector * dimextlinegap)
|
||||
layout.add_line(
|
||||
start=dimline_point,
|
||||
end=target_point,
|
||||
dxfattribs=attribs,
|
||||
)
|
||||
|
||||
def _draw_text(self, layout: "GenericLayoutType") -> None:
|
||||
"""build the dimension value text entity"""
|
||||
attribs = {
|
||||
"height": self.prop("height"),
|
||||
"color": self.prop("textcolor"),
|
||||
"layer": self.prop("layer"),
|
||||
"rotation": self.angle,
|
||||
"style": self.prop("style"),
|
||||
}
|
||||
for section in range(self.section_count):
|
||||
dimvalue_text = self._get_dimvalue_text(section)
|
||||
insert_point = self._get_text_insert_point(section)
|
||||
layout.add_text(
|
||||
text=dimvalue_text,
|
||||
dxfattribs=attribs,
|
||||
).set_placement(
|
||||
insert_point, align=TextEntityAlignment.MIDDLE_CENTER
|
||||
)
|
||||
|
||||
def _get_dimvalue_text(self, section: int) -> str:
|
||||
"""get the dimension value as text, distance from point1 to point2"""
|
||||
override = self.text_override[section]
|
||||
if len(override):
|
||||
return override
|
||||
point1, point2 = self._get_section_points(section)
|
||||
|
||||
dimvalue = distance(point1, point2) * self.prop("scale")
|
||||
return self.format_dimtext(dimvalue)
|
||||
|
||||
def _get_text_insert_point(self, section: int) -> Vec3:
|
||||
"""get the dimension value text insert point"""
|
||||
point1, point2 = self._get_section_points(section)
|
||||
dist = self.prop("height") / 2.0 + self.prop("textabove")
|
||||
return lerp(point1, point2) + (self.normal_vector * dist)
|
||||
|
||||
def _draw_ticks(self, layout: "GenericLayoutType") -> None:
|
||||
"""insert the dimension line ticks, (markers on the dimension line)"""
|
||||
attribs = {
|
||||
"xscale": self.prop("tickfactor"),
|
||||
"yscale": self.prop("tickfactor"),
|
||||
"layer": self.prop("layer"),
|
||||
}
|
||||
|
||||
def add_tick(index: int, rotate: bool = False) -> None:
|
||||
"""build the insert-entity for the tick block"""
|
||||
attribs["rotation"] = self.angle + (180.0 if rotate else 0.0)
|
||||
layout.add_blockref(
|
||||
insert=self._get_dimline_point(index),
|
||||
name=self.prop("tick"),
|
||||
dxfattribs=attribs,
|
||||
)
|
||||
|
||||
if self.prop("tick2x"):
|
||||
for index in range(0, self.point_count - 1):
|
||||
add_tick(index, False)
|
||||
for index in range(1, self.point_count):
|
||||
add_tick(index, True)
|
||||
else:
|
||||
for index in range(self.point_count):
|
||||
add_tick(index, False)
|
||||
|
||||
|
||||
class AngularDimension(_DimensionBase):
|
||||
"""
|
||||
Draw an angle dimensioning line at dimline pos from start to end, dimension text is the angle build of the three
|
||||
points start-center-end.
|
||||
|
||||
"""
|
||||
|
||||
DEG = ANGLE_DEG
|
||||
GRAD = ANGLE_GRAD
|
||||
RAD = ANGLE_RAD
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pos: UVec,
|
||||
center: UVec,
|
||||
start: UVec,
|
||||
end: UVec,
|
||||
dimstyle: str = "angle.deg",
|
||||
layer: Optional[str] = None,
|
||||
roundval: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
AngularDimension constructor.
|
||||
|
||||
Args:
|
||||
pos: location as (x, y) tuple of dimension line, line goes through this point
|
||||
center: center point as (x, y) tuple of angle
|
||||
start: line from center to start is the first side of the angle
|
||||
end: line from center to end is the second side of the angle
|
||||
dimstyle: dimstyle name, 'Default' - style is the default value
|
||||
layer: dimension line layer, override the default value of dimstyle
|
||||
roundval: count of decimal places
|
||||
|
||||
"""
|
||||
super().__init__(dimstyle, layer, roundval) # type: ignore
|
||||
self.dimlinepos = Vec3(pos)
|
||||
self.center = Vec3(center)
|
||||
self.start = Vec3(start)
|
||||
self.end = Vec3(end)
|
||||
|
||||
def _setup(self) -> None:
|
||||
"""setup calculation values"""
|
||||
self.pos_radius = distance(self.center, self.dimlinepos) # type: float
|
||||
self.radius = distance(self.center, self.start) # type: float
|
||||
self.start_vector = (self.start - self.center).normalize() # type: Vec3
|
||||
self.end_vector = (self.end - self.center).normalize() # type: Vec3
|
||||
self.start_angle = self.start_vector.angle # type: float
|
||||
self.end_angle = self.end_vector.angle # type: float
|
||||
|
||||
def render(self, layout: "GenericLayoutType") -> None:
|
||||
"""build dimension line object with basic dxf entities"""
|
||||
|
||||
self._setup()
|
||||
self._draw_dimension_line(layout)
|
||||
if self.prop("dimextline"):
|
||||
self._draw_extension_lines(layout)
|
||||
self._draw_dimension_text(layout)
|
||||
self._draw_ticks(layout)
|
||||
|
||||
def _draw_dimension_line(self, layout: "GenericLayoutType") -> None:
|
||||
"""draw the dimension line from start- to endangle."""
|
||||
layout.add_arc(
|
||||
radius=self.pos_radius,
|
||||
center=self.center,
|
||||
start_angle=degrees(self.start_angle),
|
||||
end_angle=degrees(self.end_angle),
|
||||
dxfattribs={
|
||||
"layer": self.prop("layer"),
|
||||
"color": self.prop("dimlinecolor"),
|
||||
},
|
||||
)
|
||||
|
||||
def _draw_extension_lines(self, layout: "GenericLayoutType") -> None:
|
||||
"""build the extension lines entities"""
|
||||
for vector in [self.start_vector, self.end_vector]:
|
||||
layout.add_line(
|
||||
start=self._get_extline_start(vector),
|
||||
end=self._get_extline_end(vector),
|
||||
dxfattribs={
|
||||
"layer": self.prop("layer"),
|
||||
"color": self.prop("dimextlinecolor"),
|
||||
},
|
||||
)
|
||||
|
||||
def _get_extline_start(self, vector: Vec3) -> Vec3:
|
||||
return self.center + (vector * self.prop("dimextlinegap"))
|
||||
|
||||
def _get_extline_end(self, vector: Vec3) -> Vec3:
|
||||
return self.center + (vector * self.pos_radius)
|
||||
|
||||
def _draw_dimension_text(self, layout: "GenericLayoutType") -> None:
|
||||
attribs = {
|
||||
"height": self.prop("height"),
|
||||
"rotation": degrees(
|
||||
(self.start_angle + self.end_angle) / 2 - pi / 2.0
|
||||
),
|
||||
"layer": self.prop("layer"),
|
||||
"style": self.prop("style"),
|
||||
"color": self.prop("textcolor"),
|
||||
}
|
||||
layout.add_text(
|
||||
text=self._get_dimtext(),
|
||||
dxfattribs=attribs,
|
||||
).set_placement(
|
||||
self._get_text_insert_point(),
|
||||
align=TextEntityAlignment.MIDDLE_CENTER,
|
||||
)
|
||||
|
||||
def _get_text_insert_point(self) -> Vec3:
|
||||
midvector = ((self.start_vector + self.end_vector) / 2.0).normalize()
|
||||
length = (
|
||||
self.pos_radius + self.prop("textabove") + self.prop("height") / 2.0
|
||||
)
|
||||
return self.center + (midvector * length)
|
||||
|
||||
def _draw_ticks(self, layout: "GenericLayoutType") -> None:
|
||||
attribs = {
|
||||
"xscale": self.prop("tickfactor"),
|
||||
"yscale": self.prop("tickfactor"),
|
||||
"layer": self.prop("layer"),
|
||||
}
|
||||
for vector, mirror in [
|
||||
(self.start_vector, False),
|
||||
(self.end_vector, self.prop("tick2x")),
|
||||
]:
|
||||
insert_point = self.center + (vector * self.pos_radius)
|
||||
rotation = vector.angle + pi / 2.0
|
||||
attribs["rotation"] = degrees(rotation + (pi if mirror else 0.0))
|
||||
layout.add_blockref(
|
||||
insert=insert_point,
|
||||
name=self.prop("tick"),
|
||||
dxfattribs=attribs,
|
||||
)
|
||||
|
||||
def _get_dimtext(self) -> str:
|
||||
# set scale = ANGLE_DEG for degrees (circle = 360 deg)
|
||||
# set scale = ANGLE_GRAD for grad(circle = 400 grad)
|
||||
# set scale = ANGLE_RAD for rad(circle = 2*pi)
|
||||
angle = (self.end_angle - self.start_angle) * self.prop("scale")
|
||||
return self.format_dimtext(angle)
|
||||
|
||||
|
||||
class ArcDimension(AngularDimension):
|
||||
"""
|
||||
Arc is defined by start- and endpoint on arc and the center point, or by three points lying on the arc if acr3points
|
||||
is True. Measured length goes from start- to endpoint. The dimension line goes through the dimlinepos.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pos: UVec,
|
||||
center: UVec,
|
||||
start: UVec,
|
||||
end: UVec,
|
||||
arc3points: bool = False,
|
||||
dimstyle: str = "Default",
|
||||
layer: Optional[str] = None,
|
||||
roundval: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
pos: location as (x, y) tuple of dimension line, line goes through this point
|
||||
center: center point of arc
|
||||
start: start point of arc
|
||||
end: end point of arc
|
||||
arc3points: if **True** arc is defined by three points on the arc (center, start, end)
|
||||
dimstyle: dimstyle name, 'Default' - style is the default value
|
||||
layer: dimension line layer, override the default value of dimstyle
|
||||
roundval: count of decimal places
|
||||
|
||||
"""
|
||||
super().__init__(pos, center, start, end, dimstyle, layer, roundval)
|
||||
self.arc3points = arc3points
|
||||
|
||||
def _setup(self) -> None:
|
||||
super()._setup()
|
||||
if self.arc3points:
|
||||
self.center = center_of_3points_arc(
|
||||
self.center, self.start, self.end
|
||||
)
|
||||
|
||||
def _get_extline_start(self, vector: Vec3) -> Vec3:
|
||||
return self.center + (
|
||||
vector * (self.radius + self.prop("dimextlinegap"))
|
||||
)
|
||||
|
||||
def _get_extline_end(self, vector: Vec3) -> Vec3:
|
||||
return self.center + (vector * self.pos_radius)
|
||||
|
||||
def _get_dimtext(self) -> str:
|
||||
arc_length = (
|
||||
(self.end_angle - self.start_angle)
|
||||
* self.radius
|
||||
* self.prop("scale")
|
||||
)
|
||||
return self.format_dimtext(arc_length)
|
||||
|
||||
|
||||
class RadialDimension(_DimensionBase):
|
||||
"""
|
||||
Draw a radius dimension line from `target` in direction of `center` with length drawing units. RadiusDimension has
|
||||
a special tick!!
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
center: UVec,
|
||||
target: UVec,
|
||||
length: float = 1.0,
|
||||
dimstyle: str = "Default",
|
||||
layer: Optional[str] = None,
|
||||
roundval: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
center: center point of radius
|
||||
target: target point of radius
|
||||
length: length of radius arrow (drawing length)
|
||||
dimstyle: dimstyle name, 'Default' - style is the default value
|
||||
layer: dimension line layer, override the default value of dimstyle
|
||||
roundval: count of decimal places
|
||||
|
||||
"""
|
||||
super().__init__(dimstyle, layer, roundval) # type: ignore
|
||||
self.center = Vec3(center)
|
||||
self.target = Vec3(target)
|
||||
self.length = float(length)
|
||||
|
||||
def _setup(self) -> None:
|
||||
self.target_vector = (
|
||||
self.target - self.center
|
||||
).normalize() # type: Vec3
|
||||
self.radius = distance(self.center, self.target) # type: float
|
||||
|
||||
def render(self, layout: "GenericLayoutType") -> None:
|
||||
"""build dimension line object with basic dxf entities"""
|
||||
self._setup()
|
||||
self._draw_dimension_line(layout)
|
||||
self._draw_dimension_text(layout)
|
||||
self._draw_ticks(layout)
|
||||
|
||||
def _draw_dimension_line(self, layout: "GenericLayoutType") -> None:
|
||||
start_point = self.center + (
|
||||
self.target_vector * (self.radius - self.length)
|
||||
)
|
||||
layout.add_line(
|
||||
start=start_point,
|
||||
end=self.target,
|
||||
dxfattribs={
|
||||
"color": self.prop("dimlinecolor"),
|
||||
"layer": self.prop("layer"),
|
||||
},
|
||||
)
|
||||
|
||||
def _draw_dimension_text(self, layout: "GenericLayoutType") -> None:
|
||||
layout.add_text(
|
||||
text=self._get_dimtext(),
|
||||
dxfattribs={
|
||||
"height": self.prop("height"),
|
||||
"rotation": self.target_vector.angle_deg,
|
||||
"layer": self.prop("layer"),
|
||||
"style": self.prop("style"),
|
||||
"color": self.prop("textcolor"),
|
||||
},
|
||||
).set_placement(
|
||||
self._get_insert_point(), align=TextEntityAlignment.MIDDLE_RIGHT
|
||||
)
|
||||
|
||||
def _get_insert_point(self) -> Vec3:
|
||||
return self.target - (
|
||||
self.target_vector * (self.length + self.prop("textabove"))
|
||||
)
|
||||
|
||||
def _get_dimtext(self) -> str:
|
||||
return self.format_dimtext(self.radius * self.prop("scale"))
|
||||
|
||||
def _draw_ticks(self, layout: "GenericLayoutType") -> None:
|
||||
layout.add_blockref(
|
||||
insert=self.target,
|
||||
name="DIMTICK_RADIUS",
|
||||
dxfattribs={
|
||||
"rotation": self.target_vector.angle_deg + 180,
|
||||
"xscale": self.prop("tickfactor"),
|
||||
"yscale": self.prop("tickfactor"),
|
||||
"layer": self.prop("layer"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def center_of_3points_arc(point1: UVec, point2: UVec, point3: UVec) -> Vec2:
|
||||
"""
|
||||
Calc center point of 3 point arc. ConstructionCircle is defined by 3 points
|
||||
on the circle: point1, point2 and point3.
|
||||
"""
|
||||
ray1 = ConstructionRay(point1, point2)
|
||||
ray2 = ConstructionRay(point1, point3)
|
||||
midpoint1 = lerp(point1, point2)
|
||||
midpoint2 = lerp(point1, point3)
|
||||
center_ray1 = ray1.orthogonal(midpoint1)
|
||||
center_ray2 = ray2.orthogonal(midpoint2)
|
||||
return center_ray1.intersect(center_ray2)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2020-2021, Matthew Broadway
|
||||
# License: MIT License
|
||||
|
||||
from .frontend import Frontend
|
||||
from .properties import Properties, RenderContext, LayerProperties
|
||||
@@ -0,0 +1,265 @@
|
||||
# Copyright (c) 2020-2023, Matthew Broadway
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod, ABCMeta
|
||||
from typing import Optional, Iterable
|
||||
|
||||
import numpy as np
|
||||
from typing_extensions import TypeAlias
|
||||
import dataclasses
|
||||
|
||||
from ezdxf.addons.drawing.config import Configuration
|
||||
from ezdxf.addons.drawing.properties import Properties, BackendProperties
|
||||
from ezdxf.addons.drawing.type_hints import Color
|
||||
from ezdxf.entities import DXFGraphic
|
||||
from ezdxf.math import Vec2, Matrix44
|
||||
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, single_paths
|
||||
|
||||
BkPath2d: TypeAlias = NumpyPath2d
|
||||
BkPoints2d: TypeAlias = NumpyPoints2d
|
||||
|
||||
# fmt: off
|
||||
_IMAGE_FLIP_MATRIX = [
|
||||
1.0, 0.0, 0.0, 0.0,
|
||||
0.0, -1.0, 0.0, 0.0,
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
0.0, 999, 0.0, 1.0 # index 13: 999 = image height
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ImageData:
|
||||
"""Image data.
|
||||
|
||||
Attributes:
|
||||
image: an array of RGBA pixels
|
||||
transform: the transformation to apply to the image when drawing
|
||||
(the transform from pixel coordinates to wcs)
|
||||
pixel_boundary_path: boundary path vertices in pixel coordinates, the image
|
||||
coordinate system has an inverted y-axis and the top-left corner is (0, 0)
|
||||
remove_outside: remove image outside the clipping boundary if ``True`` otherwise
|
||||
remove image inside the clipping boundary
|
||||
|
||||
"""
|
||||
|
||||
image: np.ndarray
|
||||
transform: Matrix44
|
||||
pixel_boundary_path: NumpyPoints2d
|
||||
use_clipping_boundary: bool = False
|
||||
remove_outside: bool = True
|
||||
|
||||
def image_size(self) -> tuple[int, int]:
|
||||
"""Returns the image size as tuple (width, height)."""
|
||||
image_height, image_width, *_ = self.image.shape
|
||||
return image_width, image_height
|
||||
|
||||
def flip_matrix(self) -> Matrix44:
|
||||
"""Returns the transformation matrix to align the image coordinate system with
|
||||
the WCS.
|
||||
"""
|
||||
_, image_height = self.image_size()
|
||||
_IMAGE_FLIP_MATRIX[13] = image_height
|
||||
return Matrix44(_IMAGE_FLIP_MATRIX)
|
||||
|
||||
|
||||
class BackendInterface(ABC):
|
||||
"""Public interface for 2D rendering backends."""
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: Configuration) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
|
||||
# gets the full DXF properties information
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def exit_entity(self, entity: DXFGraphic) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def set_background(self, color: Color) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def finalize(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Backend(BackendInterface, metaclass=ABCMeta):
|
||||
def __init__(self) -> None:
|
||||
self.entity_stack: list[tuple[DXFGraphic, Properties]] = []
|
||||
self.config: Configuration = Configuration()
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
self.config = config
|
||||
|
||||
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
|
||||
# gets the full DXF properties information
|
||||
self.entity_stack.append((entity, properties))
|
||||
|
||||
def exit_entity(self, entity: DXFGraphic) -> None:
|
||||
e, p = self.entity_stack.pop()
|
||||
assert e is entity, "entity stack mismatch"
|
||||
|
||||
@property
|
||||
def current_entity(self) -> Optional[DXFGraphic]:
|
||||
"""Obtain the current entity being drawn"""
|
||||
return self.entity_stack[-1][0] if self.entity_stack else None
|
||||
|
||||
@abstractmethod
|
||||
def set_background(self, color: Color) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
"""Draw a real dimensionless point, because not all backends support
|
||||
zero-length lines!
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
"""Fast method to draw a bunch of solid lines with the same properties."""
|
||||
# Must be overridden by the backend to gain a performance benefit.
|
||||
# This is the default implementation to ensure compatibility with
|
||||
# existing backends.
|
||||
for s, e in lines:
|
||||
if e.isclose(s):
|
||||
self.draw_point(s, properties)
|
||||
else:
|
||||
self.draw_line(s, e, properties)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
"""Draw an outline path (connected string of line segments and Bezier
|
||||
curves).
|
||||
|
||||
The :meth:`draw_path` implementation is a fall-back implementation
|
||||
which approximates Bezier curves by flattening as line segments.
|
||||
Backends can override this method if better path drawing functionality
|
||||
is available for that backend.
|
||||
|
||||
"""
|
||||
if len(path):
|
||||
vertices = iter(
|
||||
path.flattening(distance=self.config.max_flattening_distance)
|
||||
)
|
||||
prev = next(vertices)
|
||||
for vertex in vertices:
|
||||
self.draw_line(prev, vertex, properties)
|
||||
prev = vertex
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
"""Draw multiple filled paths (connected string of line segments and
|
||||
Bezier curves).
|
||||
|
||||
The current implementation passes these paths to the backend, all backends
|
||||
included in ezdxf handle holes by the even-odd method. If a backend requires
|
||||
oriented paths (exterior paths in counter-clockwise and holes in clockwise
|
||||
orientation) use the function :func:`oriented_paths` to separate and orient the
|
||||
input paths.
|
||||
|
||||
The default implementation draws all paths as filled polygons.
|
||||
|
||||
Args:
|
||||
paths: sequence of paths
|
||||
properties: HATCH properties
|
||||
|
||||
"""
|
||||
for path in paths:
|
||||
self.draw_filled_polygon(
|
||||
BkPoints2d(
|
||||
path.flattening(distance=self.config.max_flattening_distance)
|
||||
),
|
||||
properties,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
"""Fill a polygon whose outline is defined by the given points.
|
||||
Used to draw entities with simple outlines where :meth:`draw_path` may
|
||||
be an inefficient way to draw such a polygon.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
"""Draw an image with the given pixels."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Clear the canvas. Does not reset the internal state of the backend.
|
||||
Make sure that the previous drawing is finished before clearing.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def finalize(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def oriented_paths(paths: Iterable[BkPath2d]) -> tuple[list[BkPath2d], list[BkPath2d]]:
|
||||
"""Separate paths into exterior paths and holes. Exterior paths are oriented
|
||||
counter-clockwise, holes are oriented clockwise.
|
||||
"""
|
||||
from ezdxf.path import winding_deconstruction, make_polygon_structure
|
||||
|
||||
polygons = make_polygon_structure(single_paths(paths))
|
||||
external_paths: list[BkPath2d]
|
||||
holes: list[BkPath2d]
|
||||
external_paths, holes = winding_deconstruction(polygons)
|
||||
for p in external_paths:
|
||||
p.counter_clockwise()
|
||||
for p in holes:
|
||||
p.clockwise()
|
||||
return external_paths, holes
|
||||
@@ -0,0 +1,292 @@
|
||||
# Copyright (c) 2021-2024, Matthew Broadway
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
import warnings
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
|
||||
from ezdxf import disassemble
|
||||
from ezdxf.enums import Measurement
|
||||
from .type_hints import Color
|
||||
|
||||
|
||||
class LinePolicy(Enum):
|
||||
"""This enum is used to define how to render linetypes.
|
||||
|
||||
.. note::
|
||||
|
||||
Text and shapes in linetypes are not supported.
|
||||
|
||||
Attributes:
|
||||
SOLID: draw all lines as solid regardless of the linetype style
|
||||
ACCURATE: render styled lines as accurately as possible
|
||||
APPROXIMATE: ignored since v0.18.1 - uses always ACCURATE by default
|
||||
|
||||
"""
|
||||
|
||||
SOLID = auto()
|
||||
APPROXIMATE = auto() # ignored since v0.18.1
|
||||
ACCURATE = auto()
|
||||
|
||||
|
||||
class ProxyGraphicPolicy(Enum):
|
||||
"""The action to take when an entity with a proxy graphic is encountered
|
||||
|
||||
.. note::
|
||||
|
||||
To get proxy graphics support proxy graphics have to be loaded:
|
||||
Set the global option :attr:`ezdxf.options.load_proxy_graphics` to
|
||||
``True``, which is the default value.
|
||||
|
||||
This can not prevent drawing proxy graphic inside of blocks,
|
||||
because this is beyond the domain of the drawing add-on!
|
||||
|
||||
Attributes:
|
||||
IGNORE: do not display proxy graphics (skip_entity will be called instead)
|
||||
SHOW: if the entity cannot be rendered directly (e.g. if not implemented)
|
||||
but a proxy is present: display the proxy
|
||||
PREFER: display proxy graphics even for entities where direct rendering
|
||||
is available
|
||||
"""
|
||||
|
||||
IGNORE = auto()
|
||||
SHOW = auto()
|
||||
PREFER = auto()
|
||||
|
||||
|
||||
class HatchPolicy(Enum):
|
||||
"""The action to take when a HATCH entity is encountered
|
||||
|
||||
Attributes:
|
||||
NORMAL: render pattern and solid fillings
|
||||
IGNORE: do not show HATCH entities at all
|
||||
SHOW_OUTLINE: show only the outline of HATCH entities
|
||||
SHOW_SOLID: show HATCH entities as solid filling regardless of the pattern
|
||||
|
||||
"""
|
||||
|
||||
NORMAL = auto()
|
||||
IGNORE = auto()
|
||||
SHOW_OUTLINE = auto()
|
||||
SHOW_SOLID = auto()
|
||||
SHOW_APPROXIMATE_PATTERN = auto() # ignored since v0.18.1 == NORMAL
|
||||
|
||||
|
||||
class LineweightPolicy(Enum):
|
||||
"""This enum is used to define how to determine the lineweight.
|
||||
|
||||
Attributes:
|
||||
ABSOLUTE: in mm as resolved by the :class:`Frontend` class
|
||||
RELATIVE: lineweight is relative to page size
|
||||
RELATIVE_FIXED: fixed lineweight relative to page size for all strokes
|
||||
|
||||
"""
|
||||
|
||||
ABSOLUTE = auto()
|
||||
# set fixed lineweight for all strokes in absolute mode:
|
||||
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
|
||||
# set Configuration.lineweight_scaling to 0
|
||||
|
||||
# The RELATIVE policy is a backend feature and is not supported by all backends!
|
||||
RELATIVE = auto()
|
||||
RELATIVE_FIXED = auto()
|
||||
|
||||
|
||||
class ColorPolicy(Enum):
|
||||
"""This enum is used to define how to determine the line/fill color.
|
||||
|
||||
Attributes:
|
||||
COLOR: as resolved by the :class:`Frontend` class
|
||||
COLOR_SWAP_BW: as resolved by the :class:`Frontend` class but swaps black and white
|
||||
COLOR_NEGATIVE: invert all colors
|
||||
MONOCHROME: maps all colors to gray scale in range [0%, 100%]
|
||||
MONOCHROME_DARK_BG: maps all colors to gray scale in range [30%, 100%], brightens
|
||||
colors for dark backgrounds
|
||||
MONOCHROME_LIGHT_BG: maps all colors to gray scale in range [0%, 70%], darkens
|
||||
colors for light backgrounds
|
||||
BLACK: maps all colors to black
|
||||
WHITE: maps all colors to white
|
||||
CUSTOM: maps all colors to custom color :attr:`Configuration.custom_fg_color`
|
||||
|
||||
"""
|
||||
|
||||
COLOR = auto()
|
||||
COLOR_SWAP_BW = auto()
|
||||
COLOR_NEGATIVE = auto()
|
||||
MONOCHROME = auto()
|
||||
MONOCHROME_DARK_BG = auto()
|
||||
MONOCHROME_LIGHT_BG = auto()
|
||||
BLACK = auto()
|
||||
WHITE = auto()
|
||||
CUSTOM = auto()
|
||||
|
||||
|
||||
class BackgroundPolicy(Enum):
|
||||
"""This enum is used to define the background color.
|
||||
|
||||
Attributes:
|
||||
DEFAULT: as resolved by the :class:`Frontend` class
|
||||
WHITE: white background
|
||||
BLACK: black background
|
||||
PAPERSPACE: default paperspace background
|
||||
MODELSPACE: default modelspace background
|
||||
OFF: fully transparent background
|
||||
CUSTOM: custom background color by :attr:`Configuration.custom_bg_color`
|
||||
|
||||
"""
|
||||
|
||||
DEFAULT = auto()
|
||||
WHITE = auto()
|
||||
BLACK = auto()
|
||||
PAPERSPACE = auto()
|
||||
MODELSPACE = auto()
|
||||
OFF = auto()
|
||||
CUSTOM = auto()
|
||||
|
||||
|
||||
class TextPolicy(Enum):
|
||||
"""This enum is used to define the text rendering.
|
||||
|
||||
Attributes:
|
||||
FILLING: text is rendered as solid filling (default)
|
||||
OUTLINE: text is rendered as outline paths
|
||||
REPLACE_RECT: replace text by a rectangle
|
||||
REPLACE_FILL: replace text by a filled rectangle
|
||||
IGNORE: ignore text entirely
|
||||
|
||||
"""
|
||||
|
||||
FILLING = auto()
|
||||
OUTLINE = auto()
|
||||
REPLACE_RECT = auto()
|
||||
REPLACE_FILL = auto()
|
||||
IGNORE = auto()
|
||||
|
||||
|
||||
class ImagePolicy(Enum):
|
||||
"""This enum is used to define the image rendering.
|
||||
|
||||
Attributes:
|
||||
DISPLAY: display images as they would appear in a regular CAD application
|
||||
RECT: display images as rectangles
|
||||
MISSING: images are always rendered as-if they are missing (rectangle + path text)
|
||||
PROXY: images are rendered using their proxy representations (rectangle)
|
||||
IGNORE: ignore images entirely
|
||||
|
||||
"""
|
||||
|
||||
DISPLAY = auto()
|
||||
RECT = auto()
|
||||
MISSING = auto()
|
||||
PROXY = auto()
|
||||
IGNORE = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Configuration:
|
||||
"""Configuration options for the :mod:`drawing` add-on.
|
||||
|
||||
Attributes:
|
||||
pdsize: the size to draw POINT entities (in drawing units)
|
||||
set to None to use the $PDSIZE value from the dxf document header
|
||||
|
||||
======= ====================================================
|
||||
0 5% of draw area height
|
||||
<0 Specifies a percentage of the viewport size
|
||||
>0 Specifies an absolute size
|
||||
None use the $PDMODE value from the dxf document header
|
||||
======= ====================================================
|
||||
|
||||
pdmode: point styling mode (see POINT documentation)
|
||||
|
||||
see :class:`~ezdxf.entities.Point` class documentation
|
||||
|
||||
measurement: whether to use metric or imperial units as enum :class:`ezdxf.enums.Measurement`
|
||||
|
||||
======= ======================================================
|
||||
0 use imperial units (in, ft, yd, ...)
|
||||
1 use metric units (ISO meters)
|
||||
None use the $MEASUREMENT value from the dxf document header
|
||||
======= ======================================================
|
||||
|
||||
show_defpoints: whether to show or filter out POINT entities on the defpoints layer
|
||||
proxy_graphic_policy: the action to take when a proxy graphic is encountered
|
||||
line_policy: the method to use when drawing styled lines (eg dashed,
|
||||
dotted etc)
|
||||
hatch_policy: the method to use when drawing HATCH entities
|
||||
infinite_line_length: the length to use when drawing infinite lines
|
||||
lineweight_scaling:
|
||||
multiplies every lineweight by this factor; set this factor to 0.0 for a
|
||||
constant minimum line width defined by the :attr:`min_lineweight` setting
|
||||
for all lineweights;
|
||||
the correct DXF lineweight often looks too thick in SVG, so setting a
|
||||
factor < 1 can improve the visual appearance
|
||||
min_lineweight: the minimum line width in 1/300 inch; set to ``None`` for
|
||||
let the backend choose.
|
||||
min_dash_length: the minimum length for a dash when drawing a styled line
|
||||
(default value is arbitrary)
|
||||
max_flattening_distance: Max flattening distance in drawing units
|
||||
see Path.flattening documentation.
|
||||
The backend implementation should calculate an appropriate value,
|
||||
like 1 screen- or paper pixel on the output medium, but converted
|
||||
into drawing units. Sets Path() approximation accuracy
|
||||
circle_approximation_count: Approximate a full circle by `n` segments, arcs
|
||||
have proportional less segments. Only used for approximation of arcs
|
||||
in banded polylines.
|
||||
hatching_timeout: hatching timeout for a single entity, very dense
|
||||
hatching patterns can cause a very long execution time, the default
|
||||
timeout for a single entity is 30 seconds.
|
||||
min_hatch_line_distance: minimum hatch line distance to render, narrower pattern
|
||||
lines are rendered as solid filling
|
||||
color_policy:
|
||||
custom_fg_color: Used for :class:`ColorPolicy.custom` policy, custom foreground
|
||||
color as "#RRGGBBAA" color string (RGB+alpha)
|
||||
background_policy:
|
||||
custom_bg_color: Used for :class:`BackgroundPolicy.custom` policy, custom
|
||||
background color as "#RRGGBBAA" color string (RGB+alpha)
|
||||
lineweight_policy:
|
||||
text_policy:
|
||||
image_policy: the method for drawing IMAGE entities
|
||||
|
||||
"""
|
||||
|
||||
pdsize: Optional[int] = None # use $PDSIZE from HEADER section
|
||||
pdmode: Optional[int] = None # use $PDMODE from HEADER section
|
||||
measurement: Optional[Measurement] = None
|
||||
show_defpoints: bool = False
|
||||
proxy_graphic_policy: ProxyGraphicPolicy = ProxyGraphicPolicy.SHOW
|
||||
line_policy: LinePolicy = LinePolicy.ACCURATE
|
||||
hatch_policy: HatchPolicy = HatchPolicy.NORMAL
|
||||
infinite_line_length: float = 20
|
||||
lineweight_scaling: float = 1.0
|
||||
min_lineweight: Optional[float] = None
|
||||
min_dash_length: float = 0.1
|
||||
max_flattening_distance: float = disassemble.Primitive.max_flattening_distance
|
||||
circle_approximation_count: int = 128
|
||||
hatching_timeout: float = 30.0
|
||||
# Keep value in sync with ezdxf.render.hatching.MIN_HATCH_LINE_DISTANCE
|
||||
min_hatch_line_distance: float = 1e-4
|
||||
color_policy: ColorPolicy = ColorPolicy.COLOR
|
||||
custom_fg_color: Color = "#000000"
|
||||
background_policy: BackgroundPolicy = BackgroundPolicy.DEFAULT
|
||||
custom_bg_color: Color = "#ffffff"
|
||||
lineweight_policy: LineweightPolicy = LineweightPolicy.ABSOLUTE
|
||||
text_policy: TextPolicy = TextPolicy.FILLING
|
||||
image_policy: ImagePolicy = ImagePolicy.DISPLAY
|
||||
|
||||
@staticmethod
|
||||
def defaults() -> Configuration:
|
||||
warnings.warn(
|
||||
"use Configuration() instead of Configuration.defaults()",
|
||||
DeprecationWarning,
|
||||
)
|
||||
return Configuration()
|
||||
|
||||
def with_changes(self, **kwargs) -> Configuration:
|
||||
"""Returns a new frozen :class:`Configuration` object with modified values."""
|
||||
params = dataclasses.asdict(self)
|
||||
for k, v in kwargs.items():
|
||||
params[k] = v
|
||||
return Configuration(**params)
|
||||
@@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2021-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable
|
||||
|
||||
from ezdxf.math import Vec2
|
||||
from .properties import BackendProperties
|
||||
from .backend import Backend, BkPath2d, BkPoints2d, ImageData
|
||||
from .config import Configuration
|
||||
|
||||
|
||||
class BasicBackend(Backend):
|
||||
"""The basic backend has no draw_path() support and approximates all curves
|
||||
by lines.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.collector = []
|
||||
self.configure(Configuration())
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
self.collector.append(("point", pos, properties))
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
self.collector.append(("line", start, end, properties))
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
self.collector.append(("filled_polygon", points, properties))
|
||||
|
||||
def draw_image(
|
||||
self, image_data: ImageData, properties: BackendProperties
|
||||
) -> None:
|
||||
self.collector.append(("image", image_data, properties))
|
||||
|
||||
def set_background(self, color: str) -> None:
|
||||
self.collector.append(("bgcolor", color))
|
||||
|
||||
def clear(self) -> None:
|
||||
self.collector = []
|
||||
|
||||
|
||||
class PathBackend(BasicBackend):
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
self.collector.append(("path", path, properties))
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
self.collector.append(("filled_path", tuple(paths), properties))
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) 2020-2021, Matthew Broadway
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
|
||||
from ezdxf.addons.drawing.backend import BackendInterface
|
||||
from ezdxf.addons.drawing.type_hints import Color
|
||||
from ezdxf.math import Vec3
|
||||
|
||||
|
||||
def draw_rect(points: list[Vec3], color: Color, out: BackendInterface):
|
||||
from ezdxf.addons.drawing.properties import BackendProperties
|
||||
|
||||
props = BackendProperties(color=color)
|
||||
for a, b in zip(points, points[1:]):
|
||||
out.draw_line(a, b, props)
|
||||
@@ -0,0 +1,223 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, TYPE_CHECKING, no_type_check
|
||||
from functools import lru_cache
|
||||
import enum
|
||||
import numpy as np
|
||||
|
||||
from ezdxf import colors
|
||||
from ezdxf.lldxf.const import VALID_DXF_LINEWEIGHTS
|
||||
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
|
||||
from ezdxf.path import to_splines_and_polylines, to_hatches
|
||||
from ezdxf.layouts import BaseLayout
|
||||
|
||||
from .type_hints import Color
|
||||
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
|
||||
from .config import Configuration
|
||||
from .properties import BackendProperties
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.entities import Solid
|
||||
|
||||
|
||||
class ColorMode(enum.Enum):
|
||||
"""This enum is used to define the color output mode of the :class:`DXFBackend`.
|
||||
|
||||
Attributes:
|
||||
ACI: the color is set as :ref:`ACI` and assigned by layer
|
||||
RGB: the color is set as RGB true color value
|
||||
|
||||
"""
|
||||
|
||||
# Use color index as primary color
|
||||
ACI = enum.auto()
|
||||
|
||||
# Use always the RGB value
|
||||
RGB = enum.auto()
|
||||
|
||||
|
||||
DARK_COLOR_THRESHOLD = 0.2
|
||||
RGB_BLACK = colors.RGB(0, 0, 0)
|
||||
BYLAYER = 256
|
||||
|
||||
|
||||
class DXFBackend(BackendInterface):
|
||||
"""The :class:`DXFBackend` creates simple DXF files of POINT, LINE, LWPOLYLINE and
|
||||
HATCH entities. This backend does ot need any additional packages.
|
||||
|
||||
Args:
|
||||
layout: a DXF :class:`~ezdxf.layouts.BaseLayout`
|
||||
color_mode: see :class:`ColorMode`
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, layout: BaseLayout, color_mode: ColorMode = ColorMode.RGB
|
||||
) -> None:
|
||||
assert layout.doc is not None, "valid DXF document required"
|
||||
super().__init__()
|
||||
self.layout = layout
|
||||
self.doc = layout.doc
|
||||
self.color_mode = color_mode
|
||||
self.bg_color = RGB_BLACK
|
||||
self.is_dark_bg = True
|
||||
self._layers: dict[int, str] = dict()
|
||||
self._dxfattribs: dict[int, dict] = dict()
|
||||
|
||||
def set_background(self, color: Color) -> None:
|
||||
self.bg_color = colors.RGB.from_hex(color)
|
||||
self.is_dark_bg = self.bg_color.luminance < DARK_COLOR_THRESHOLD
|
||||
|
||||
def get_layer_name(self, pen: int) -> str:
|
||||
try:
|
||||
return self._layers[pen]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
layer_name = f"PEN_{pen:03d}"
|
||||
self._layers[pen] = layer_name
|
||||
if not self.doc.layers.has_entry(layer_name):
|
||||
self.doc.layers.add(layer_name, color=pen)
|
||||
return layer_name
|
||||
|
||||
def resolve_properties(self, properties: BackendProperties) -> dict:
|
||||
key = hash(properties)
|
||||
try:
|
||||
return self._dxfattribs[key]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
rgb = properties.rgb
|
||||
pen = properties.pen
|
||||
if pen < 1 or pen > 255:
|
||||
pen = 7
|
||||
aci = pen
|
||||
if self.color_mode == ColorMode.ACI:
|
||||
aci = BYLAYER
|
||||
attribs = {
|
||||
"color": aci,
|
||||
"layer": self.get_layer_name(pen),
|
||||
"lineweight": make_lineweight(properties.lineweight),
|
||||
}
|
||||
if self.color_mode == ColorMode.RGB:
|
||||
attribs["true_color"] = colors.rgb2int(rgb)
|
||||
|
||||
alpha = properties.color[7:9]
|
||||
if alpha:
|
||||
try:
|
||||
f = int(alpha, 16) / 255
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
attribs["transparency"] = colors.float2transparency(f)
|
||||
self._dxfattribs[key] = attribs
|
||||
return attribs
|
||||
|
||||
def set_solid_fill(self, hatch, properties: BackendProperties) -> None:
|
||||
rgb: colors.RGB | None = None
|
||||
aci = BYLAYER
|
||||
if self.color_mode == ColorMode.RGB:
|
||||
rgb = properties.rgb
|
||||
aci = properties.pen
|
||||
hatch.set_solid_fill(color=aci, style=0, rgb=rgb)
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
self.layout.add_point(pos, dxfattribs=self.resolve_properties(properties))
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
self.layout.add_line(start, end, dxfattribs=self.resolve_properties(properties))
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
lines = list(lines)
|
||||
if len(lines) == 0:
|
||||
return
|
||||
attribs = self.resolve_properties(properties)
|
||||
for start, end in lines:
|
||||
self.layout.add_line(start, end, dxfattribs=attribs)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
attribs = self.resolve_properties(properties)
|
||||
if path.has_curves:
|
||||
for entity in to_splines_and_polylines(path, dxfattribs=attribs): # type: ignore
|
||||
self.layout.add_entity(entity)
|
||||
else:
|
||||
self.layout.add_lwpolyline(path.control_vertices(), dxfattribs=attribs)
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
attribs = self.resolve_properties(properties)
|
||||
py_paths = [p.to_path() for p in paths]
|
||||
for hatch in to_hatches(py_paths, dxfattribs=attribs):
|
||||
self.layout.add_entity(hatch)
|
||||
self.set_solid_fill(hatch, properties)
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
hatch = self.layout.add_hatch(dxfattribs=self.resolve_properties(properties))
|
||||
hatch.paths.add_polyline_path(points.vertices(), is_closed=True)
|
||||
self.set_solid_fill(hatch, properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
pass # TODO: not implemented
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def finalize(self) -> None:
|
||||
pass
|
||||
|
||||
def enter_entity(self, entity, properties) -> None:
|
||||
pass
|
||||
|
||||
def exit_entity(self, entity) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def alpha_to_transparency(alpha: int) -> float:
|
||||
return colors.float2transparency(alpha / 255)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def make_lineweight(width: float) -> int:
|
||||
width_int = int(width * 100)
|
||||
for lw in VALID_DXF_LINEWEIGHTS:
|
||||
if width_int <= lw:
|
||||
return lw
|
||||
return VALID_DXF_LINEWEIGHTS[-1]
|
||||
|
||||
|
||||
@no_type_check
|
||||
def update_extents(doc: Drawing, bbox: BoundingBox2d) -> None:
|
||||
doc.header["$EXTMIN"] = (bbox.extmin.x, bbox.extmin.y, 0)
|
||||
doc.header["$EXTMAX"] = (bbox.extmax.x, bbox.extmax.y, 0)
|
||||
|
||||
|
||||
def setup_paperspace(doc: Drawing, bbox: BoundingBox2d):
|
||||
psp_size = bbox.size / 40.0 # plu to mm
|
||||
psp_center = psp_size * 0.5
|
||||
psp = doc.paperspace()
|
||||
psp.page_setup(size=(psp_size.x, psp_size.y), margins=(0, 0, 0, 0), units="mm")
|
||||
psp.add_viewport(
|
||||
center=psp_center,
|
||||
size=(psp_size.x, psp_size.y),
|
||||
view_center_point=bbox.center,
|
||||
view_height=bbox.size.y,
|
||||
status=2,
|
||||
)
|
||||
|
||||
|
||||
def add_background(msp: BaseLayout, bbox: BoundingBox2d, color: colors.RGB) -> Solid:
|
||||
v = bbox.rect_vertices()
|
||||
bg = msp.add_solid(
|
||||
[v[0], v[1], v[3], v[2]], dxfattribs={"true_color": colors.rgb2int(color)}
|
||||
)
|
||||
return bg
|
||||
@@ -0,0 +1,228 @@
|
||||
import pathlib
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
import subprocess
|
||||
import os
|
||||
import platform
|
||||
|
||||
from ezdxf.addons.drawing.backend import BackendInterface
|
||||
|
||||
|
||||
class FileOutputRenderBackend(ABC):
|
||||
def __init__(self, dpi: float) -> None:
|
||||
self._dpi = dpi
|
||||
|
||||
@abstractmethod
|
||||
def supported_formats(self) -> list[tuple[str, str]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def default_format(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def backend(self) -> BackendInterface:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def save(self, output: pathlib.Path) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MatplotlibFileOutput(FileOutputRenderBackend):
|
||||
def __init__(self, dpi: float) -> None:
|
||||
super().__init__(dpi)
|
||||
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError:
|
||||
raise ImportError("Matplotlib not found") from None
|
||||
|
||||
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
|
||||
|
||||
self._plt = plt
|
||||
self._fig = plt.figure()
|
||||
self._ax = self._fig.add_axes((0, 0, 1, 1))
|
||||
self._backend = MatplotlibBackend(self._ax)
|
||||
|
||||
def supported_formats(self) -> list[tuple[str, str]]:
|
||||
return list(self._fig.canvas.get_supported_filetypes().items())
|
||||
|
||||
def default_format(self) -> str:
|
||||
return "png"
|
||||
|
||||
def backend(self) -> BackendInterface:
|
||||
return self._backend
|
||||
|
||||
def save(self, output: pathlib.Path) -> None:
|
||||
self._fig.savefig(output, dpi=self._dpi)
|
||||
self._plt.close(self._fig)
|
||||
|
||||
|
||||
class PyQtFileOutput(FileOutputRenderBackend):
|
||||
def __init__(self, dpi: float) -> None:
|
||||
super().__init__(dpi)
|
||||
|
||||
try:
|
||||
from ezdxf.addons.xqt import QtCore, QtGui, QtWidgets
|
||||
from ezdxf.addons.drawing.pyqt import PyQtBackend
|
||||
except ImportError:
|
||||
raise ImportError("PyQt not found") from None
|
||||
|
||||
self._qc = QtCore
|
||||
self._qg = QtGui
|
||||
self._qw = QtWidgets
|
||||
self._app = QtWidgets.QApplication(sys.argv)
|
||||
self._scene = QtWidgets.QGraphicsScene()
|
||||
self._backend = PyQtBackend()
|
||||
self._backend.set_scene(self._scene)
|
||||
|
||||
def supported_formats(self) -> list[tuple[str, str]]:
|
||||
# https://doc.qt.io/qt-6/qimage.html#reading-and-writing-image-files
|
||||
return [
|
||||
("bmp", "Windows Bitmap"),
|
||||
("jpg", "Joint Photographic Experts Group"),
|
||||
("jpeg", "Joint Photographic Experts Group"),
|
||||
("png", "Portable Network Graphics"),
|
||||
("ppm", "Portable Pixmap"),
|
||||
("xbm", "X11 Bitmap"),
|
||||
("xpm", "X11 Pixmap"),
|
||||
("svg", "Scalable Vector Graphics"),
|
||||
]
|
||||
|
||||
def default_format(self) -> str:
|
||||
return "png"
|
||||
|
||||
def backend(self) -> BackendInterface:
|
||||
return self._backend
|
||||
|
||||
def save(self, output: pathlib.Path) -> None:
|
||||
if output.suffix.lower() == ".svg":
|
||||
from PySide6.QtSvg import QSvgGenerator
|
||||
|
||||
generator = QSvgGenerator()
|
||||
generator.setFileName(str(output))
|
||||
generator.setResolution(int(self._dpi))
|
||||
scene_rect = self._scene.sceneRect()
|
||||
output_size = self._qc.QSize(
|
||||
round(scene_rect.size().width()), round(scene_rect.size().height())
|
||||
)
|
||||
generator.setSize(output_size)
|
||||
generator.setViewBox(
|
||||
self._qc.QRect(0, 0, output_size.width(), output_size.height())
|
||||
)
|
||||
|
||||
painter = self._qg.QPainter()
|
||||
|
||||
transform = self._qg.QTransform()
|
||||
transform.scale(1, -1)
|
||||
transform.translate(0, -output_size.height())
|
||||
|
||||
painter.begin(generator)
|
||||
painter.setWorldTransform(transform, combine=True)
|
||||
painter.setRenderHint(self._qg.QPainter.RenderHint.Antialiasing)
|
||||
self._scene.render(painter)
|
||||
painter.end()
|
||||
|
||||
else:
|
||||
|
||||
view = self._qw.QGraphicsView(self._scene)
|
||||
view.setRenderHint(self._qg.QPainter.RenderHint.Antialiasing)
|
||||
sizef: QRectF = self._scene.sceneRect() * self._dpi / 92 # type: ignore
|
||||
image = self._qg.QImage(
|
||||
self._qc.QSize(round(sizef.width()), round(sizef.height())),
|
||||
self._qg.QImage.Format.Format_ARGB32,
|
||||
)
|
||||
painter = self._qg.QPainter(image)
|
||||
painter.setRenderHint(self._qg.QPainter.RenderHint.Antialiasing)
|
||||
painter.fillRect(image.rect(), self._scene.backgroundBrush())
|
||||
self._scene.render(painter)
|
||||
painter.end()
|
||||
image.mirror(False, True)
|
||||
image.save(str(output))
|
||||
|
||||
|
||||
class MuPDFFileOutput(FileOutputRenderBackend):
|
||||
def __init__(self, dpi: float) -> None:
|
||||
super().__init__(dpi)
|
||||
|
||||
from ezdxf.addons.drawing.pymupdf import PyMuPdfBackend, is_pymupdf_installed
|
||||
|
||||
if not is_pymupdf_installed:
|
||||
raise ImportError("PyMuPDF not found")
|
||||
self._backend = PyMuPdfBackend()
|
||||
|
||||
def supported_formats(self) -> list[tuple[str, str]]:
|
||||
# https://pymupdf.readthedocs.io/en/latest/pixmap.html#pixmapoutput
|
||||
return [
|
||||
("pdf", "Portable Document Format"),
|
||||
("svg", "Scalable Vector Graphics"),
|
||||
("jpg", "Joint Photographic Experts Group"),
|
||||
("jpeg", "Joint Photographic Experts Group"),
|
||||
("pam", "Portable Arbitrary Map"),
|
||||
("pbm", "Portable Bitmap"),
|
||||
("pgm", "Portable Graymap"),
|
||||
("png", "Portable Network Graphics"),
|
||||
("pnm", "Portable Anymap"),
|
||||
("ppm", "Portable Pixmap (no alpha channel)"),
|
||||
("ps", "Adobe PostScript Image"),
|
||||
("psd", "Adobe Photoshop Document"),
|
||||
]
|
||||
|
||||
def default_format(self) -> str:
|
||||
return "pdf"
|
||||
|
||||
def backend(self) -> BackendInterface:
|
||||
return self._backend
|
||||
|
||||
def save(self, output: pathlib.Path) -> None:
|
||||
from ezdxf.addons.drawing import layout
|
||||
|
||||
backend = self._backend.get_replay(layout.Page(0, 0))
|
||||
if output.suffix == ".pdf":
|
||||
output.write_bytes(backend.get_pdf_bytes())
|
||||
elif output.suffix == ".svg":
|
||||
output.write_text(backend.get_svg_image())
|
||||
else:
|
||||
pixmap = backend.get_pixmap(int(self._dpi), alpha=True)
|
||||
pixmap.save(str(output))
|
||||
|
||||
|
||||
class SvgFileOutput(FileOutputRenderBackend):
|
||||
def __init__(self, dpi: float) -> None:
|
||||
super().__init__(dpi)
|
||||
|
||||
from ezdxf.addons.drawing.svg import SVGBackend
|
||||
|
||||
self._backend = SVGBackend()
|
||||
|
||||
def supported_formats(self) -> list[tuple[str, str]]:
|
||||
return [("svg", "Scalable Vector Graphics")]
|
||||
|
||||
def default_format(self) -> str:
|
||||
return "svg"
|
||||
|
||||
def backend(self) -> BackendInterface:
|
||||
return self._backend
|
||||
|
||||
def save(self, output: pathlib.Path) -> None:
|
||||
from ezdxf.addons.drawing import layout
|
||||
|
||||
output.write_text(self._backend.get_string(layout.Page(0, 0)))
|
||||
|
||||
|
||||
def open_file(path: pathlib.Path) -> None:
|
||||
"""open the given path in the default application"""
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
subprocess.call(
|
||||
["open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
elif system == "Windows":
|
||||
os.startfile(str(path)) # type: ignore
|
||||
else:
|
||||
subprocess.call(
|
||||
["xdg-open", str(path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) 2021-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Iterator
|
||||
from ezdxf.entities import DXFGraphic, DXFEntity
|
||||
from ezdxf.lldxf import const
|
||||
from ezdxf.lldxf.tagwriter import AbstractTagWriter
|
||||
from ezdxf.protocols import SupportsVirtualEntities
|
||||
from ezdxf.entities.copy import default_copy, CopyNotSupported
|
||||
|
||||
|
||||
class DXFGraphicProxy(DXFGraphic):
|
||||
"""DO NOT USE THIS WRAPPER AS REAL DXF ENTITY OUTSIDE THE DRAWING ADD-ON!"""
|
||||
|
||||
def __init__(self, entity: DXFEntity):
|
||||
super().__init__()
|
||||
self.entity = entity
|
||||
self.dxf = self._setup_dxf_namespace(entity)
|
||||
|
||||
def _setup_dxf_namespace(self, entity):
|
||||
# copy DXF namespace - modifications do not effect the wrapped entity
|
||||
dxf = entity.dxf.copy(self)
|
||||
# setup mandatory DXF attributes without default values like layer:
|
||||
for k, v in self.DEFAULT_ATTRIBS.items():
|
||||
if not dxf.hasattr(k):
|
||||
dxf.set(k, v)
|
||||
return dxf
|
||||
|
||||
def dxftype(self) -> str:
|
||||
return self.entity.dxftype()
|
||||
|
||||
def __virtual_entities__(self) -> Iterator[DXFGraphic]:
|
||||
"""Implements the SupportsVirtualEntities protocol."""
|
||||
if isinstance(self.entity, SupportsVirtualEntities):
|
||||
return self.entity.__virtual_entities__()
|
||||
if hasattr(self.entity, "virtual_entities"):
|
||||
return self.entity.virtual_entities()
|
||||
return iter([])
|
||||
|
||||
def virtual_entities(self) -> Iterable[DXFGraphic]:
|
||||
return self.__virtual_entities__()
|
||||
|
||||
def copy(self, copy_strategy=default_copy) -> DXFGraphicProxy:
|
||||
raise CopyNotSupported(f"Copying of DXFGraphicProxy() not supported.")
|
||||
|
||||
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
|
||||
# prevent dxf export
|
||||
return False
|
||||
@@ -0,0 +1,549 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence, no_type_check
|
||||
import copy
|
||||
import numpy as np
|
||||
|
||||
from ezdxf import colors
|
||||
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
|
||||
from ezdxf.path import Command
|
||||
|
||||
from .type_hints import Color
|
||||
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
|
||||
from .config import Configuration, LineweightPolicy
|
||||
from .properties import BackendProperties
|
||||
from . import layout, recorder
|
||||
|
||||
|
||||
__all__ = ["PlotterBackend"]
|
||||
|
||||
SEMICOLON = ord(";")
|
||||
PRELUDE = b"%0B;IN;BP;"
|
||||
EPILOG = b"PU;PA0,0;"
|
||||
FLATTEN_MAX = 10 # plot units
|
||||
MM_TO_PLU = 40 # 40 plu = 1mm
|
||||
DEFAULT_PEN = 0
|
||||
WHITE = colors.RGB(255, 255, 255)
|
||||
BLACK = colors.RGB(0, 0, 0)
|
||||
MAX_FLATTEN = 10
|
||||
|
||||
# comparing Command.<attrib> to ints is very slow
|
||||
CMD_MOVE_TO = int(Command.MOVE_TO)
|
||||
CMD_LINE_TO = int(Command.LINE_TO)
|
||||
CMD_CURVE3_TO = int(Command.CURVE3_TO)
|
||||
CMD_CURVE4_TO = int(Command.CURVE4_TO)
|
||||
|
||||
|
||||
class PlotterBackend(recorder.Recorder):
|
||||
"""The :class:`PlotterBackend` creates HPGL/2 plot files for output on raster
|
||||
plotters. This backend does not need any additional packages. This backend support
|
||||
content cropping at page margins.
|
||||
|
||||
The plot files are tested by the plot file viewer `ViewCompanion Standard`_
|
||||
but not on real hardware - please use with care and give feedback.
|
||||
|
||||
.. _ViewCompanion Standard: http://www.softwarecompanions.com/
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
def get_bytes(
|
||||
self,
|
||||
page: layout.Page,
|
||||
*,
|
||||
settings: layout.Settings = layout.Settings(),
|
||||
render_box: BoundingBox2d | None = None,
|
||||
curves=True,
|
||||
decimal_places: int = 1,
|
||||
base=64,
|
||||
) -> bytes:
|
||||
"""Returns the HPGL/2 data as bytes.
|
||||
|
||||
Args:
|
||||
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
|
||||
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
|
||||
render_box: set explicit region to render, default is content bounding box
|
||||
curves: use Bèzier curves for HPGL/2 output
|
||||
decimal_places: HPGL/2 output precision, less decimal places creates smaller
|
||||
files but for the price of imprecise curves (text)
|
||||
base: base for polyline encoding, 32 for 7 bit encoding or 64 for 8 bit encoding
|
||||
|
||||
"""
|
||||
top_origin = False
|
||||
settings = copy.copy(settings)
|
||||
# This player changes the original recordings!
|
||||
player = self.player()
|
||||
if render_box is None:
|
||||
render_box = player.bbox()
|
||||
|
||||
# the page origin (0, 0) is in the bottom-left corner.
|
||||
output_layout = layout.Layout(render_box, flip_y=False)
|
||||
page = output_layout.get_final_page(page, settings)
|
||||
if page.width == 0 or page.height == 0:
|
||||
return b"" # empty page
|
||||
# DXF coordinates are mapped to integer coordinates (plu) in the first
|
||||
# quadrant: 40 plu = 1mm
|
||||
settings.output_coordinate_space = (
|
||||
max(page.width_in_mm, page.height_in_mm) * MM_TO_PLU
|
||||
)
|
||||
# transform content to the output coordinates space:
|
||||
m = output_layout.get_placement_matrix(
|
||||
page, settings=settings, top_origin=top_origin
|
||||
)
|
||||
player.transform(m)
|
||||
if settings.crop_at_margins:
|
||||
p1, p2 = page.get_margin_rect(top_origin=top_origin) # in mm
|
||||
# scale factor to map page coordinates to output space coordinates:
|
||||
output_scale = settings.page_output_scale_factor(page)
|
||||
max_sagitta = 0.1 * MM_TO_PLU # curve approximation 0.1 mm
|
||||
# crop content inplace by the margin rect:
|
||||
player.crop_rect(p1 * output_scale, p2 * output_scale, max_sagitta)
|
||||
backend = _RenderBackend(
|
||||
page,
|
||||
settings=settings,
|
||||
curves=curves,
|
||||
decimal_places=decimal_places,
|
||||
base=base,
|
||||
)
|
||||
player.replay(backend)
|
||||
return backend.get_bytes()
|
||||
|
||||
def compatible(
|
||||
self, page: layout.Page, settings: layout.Settings = layout.Settings()
|
||||
) -> bytes:
|
||||
"""Returns the HPGL/2 data as 7-bit encoded bytes curves as approximated
|
||||
polylines and coordinates are rounded to integer values.
|
||||
Has often the smallest file size and should be compatible to all output devices
|
||||
but has a low quality text rendering.
|
||||
"""
|
||||
return self.get_bytes(
|
||||
page, settings=settings, curves=False, decimal_places=0, base=32
|
||||
)
|
||||
|
||||
def low_quality(
|
||||
self, page: layout.Page, settings: layout.Settings = layout.Settings()
|
||||
) -> bytes:
|
||||
"""Returns the HPGL/2 data as 8-bit encoded bytes, curves as Bézier
|
||||
curves and coordinates are rounded to integer values.
|
||||
Has a smaller file size than normal quality and the output device must support
|
||||
8-bit encoding and Bèzier curves.
|
||||
"""
|
||||
return self.get_bytes(
|
||||
page, settings=settings, curves=True, decimal_places=0, base=64
|
||||
)
|
||||
|
||||
def normal_quality(
|
||||
self, page: layout.Page, settings: layout.Settings = layout.Settings()
|
||||
) -> bytes:
|
||||
"""Returns the HPGL/2 data as 8-bit encoded bytes, curves as Bézier
|
||||
curves and coordinates are floats rounded to one decimal place.
|
||||
Has a smaller file size than high quality and the output device must support
|
||||
8-bit encoding, Bèzier curves and fractional coordinates.
|
||||
"""
|
||||
return self.get_bytes(
|
||||
page, settings=settings, curves=True, decimal_places=1, base=64
|
||||
)
|
||||
|
||||
def high_quality(
|
||||
self, page: layout.Page, settings: layout.Settings = layout.Settings()
|
||||
) -> bytes:
|
||||
"""Returns the HPGL/2 data as 8-bit encoded bytes and all curves as Bézier
|
||||
curves and coordinates are floats rounded to two decimal places.
|
||||
Has the largest file size and the output device must support 8-bit encoding,
|
||||
Bèzier curves and fractional coordinates.
|
||||
"""
|
||||
return self.get_bytes(
|
||||
page, settings=settings, curves=True, decimal_places=2, base=64
|
||||
)
|
||||
|
||||
|
||||
class PenTable:
|
||||
def __init__(self, max_pens: int = 64) -> None:
|
||||
self.pens: dict[int, colors.RGB] = dict()
|
||||
self.max_pens = int(max_pens)
|
||||
|
||||
def __contains__(self, index: int) -> bool:
|
||||
return index in self.pens
|
||||
|
||||
def __getitem__(self, index: int) -> colors.RGB:
|
||||
return self.pens[index]
|
||||
|
||||
def add_pen(self, index: int, color: colors.RGB):
|
||||
self.pens[index] = color
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
command: list[bytes] = [f"NP{self.max_pens-1};".encode()]
|
||||
pens: list[tuple[int, colors.RGB]] = [
|
||||
(index, rgb) for index, rgb in self.pens.items()
|
||||
]
|
||||
pens.sort()
|
||||
for index, rgb in pens:
|
||||
command.append(make_pc(index, rgb))
|
||||
return b"".join(command)
|
||||
|
||||
|
||||
def make_pc(pen: int, rgb: colors.RGB) -> bytes:
|
||||
# pen color
|
||||
return f"PC{pen},{rgb.r},{rgb.g},{rgb.b};".encode()
|
||||
|
||||
|
||||
class _RenderBackend(BackendInterface):
|
||||
"""Creates the HPGL/2 output.
|
||||
|
||||
This backend requires some preliminary work, record the frontend output via the
|
||||
Recorder backend to accomplish the following requirements:
|
||||
|
||||
- Move content in the first quadrant of the coordinate system.
|
||||
- The output coordinates are integer values, scale the content appropriately:
|
||||
- 1 plot unit (plu) = 0.025mm
|
||||
- 40 plu = 1mm
|
||||
- 1016 plu = 1 inch
|
||||
- 3.39 plu = 1 dot @300 dpi
|
||||
- Replay the recorded output on this backend.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page: layout.Page,
|
||||
*,
|
||||
settings: layout.Settings,
|
||||
curves=True,
|
||||
decimal_places: int = 2,
|
||||
base: int = 64,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.curves = curves
|
||||
self.factional_bits = round(decimal_places * 3.33)
|
||||
self.decimal_places: int | None = (
|
||||
int(decimal_places) if decimal_places else None
|
||||
)
|
||||
self.base = base
|
||||
self.header: list[bytes] = []
|
||||
self.data: list[bytes] = []
|
||||
self.pen_table = PenTable(max_pens=256)
|
||||
self.current_pen: int = 0
|
||||
self.current_pen_width: float = 0.0
|
||||
self.setup(page)
|
||||
|
||||
self._stroke_width_cache: dict[float, float] = dict()
|
||||
# StrokeWidthPolicy.absolute:
|
||||
# pen width in mm as resolved by the frontend
|
||||
self.min_lineweight = 0.05 # in mm, set by configure()
|
||||
self.lineweight_scaling = 1.0 # set by configure()
|
||||
self.lineweight_policy = LineweightPolicy.ABSOLUTE # set by configure()
|
||||
# fixed lineweight for all strokes in ABSOLUTE mode:
|
||||
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
|
||||
# set Configuration.lineweight_scaling to 0
|
||||
|
||||
# LineweightPolicy.RELATIVE:
|
||||
# max_stroke_width is determined as a certain percentage of max(width, height)
|
||||
max_size = max(page.width_in_mm, page.height_in_mm)
|
||||
self.max_stroke_width: float = round(max_size * settings.max_stroke_width, 2)
|
||||
# min_stroke_width is determined as a certain percentage of max_stroke_width
|
||||
self.min_stroke_width: float = round(
|
||||
self.max_stroke_width * settings.min_stroke_width, 2
|
||||
)
|
||||
# LineweightPolicy.RELATIVE_FIXED:
|
||||
# all strokes have a fixed stroke-width as a certain percentage of max_stroke_width
|
||||
self.fixed_stroke_width: float = round(
|
||||
self.max_stroke_width * settings.fixed_stroke_width, 2
|
||||
)
|
||||
|
||||
def setup(self, page: layout.Page) -> None:
|
||||
cmd = f"PS{page.width_in_mm*MM_TO_PLU:.0f},{page.height_in_mm*MM_TO_PLU:.0f};"
|
||||
self.header.append(cmd.encode())
|
||||
self.header.append(b"FT1;PA;") # solid fill; plot absolute;
|
||||
|
||||
def get_bytes(self) -> bytes:
|
||||
header: list[bytes] = list(self.header)
|
||||
header.append(self.pen_table.to_bytes())
|
||||
return compile_hpgl2(header, self.data)
|
||||
|
||||
def switch_current_pen(self, pen_number: int, rgb: colors.RGB) -> int:
|
||||
if pen_number in self.pen_table:
|
||||
pen_color = self.pen_table[pen_number]
|
||||
if rgb != pen_color:
|
||||
self.data.append(make_pc(DEFAULT_PEN, rgb))
|
||||
pen_number = DEFAULT_PEN
|
||||
else:
|
||||
self.pen_table.add_pen(pen_number, rgb)
|
||||
return pen_number
|
||||
|
||||
def set_pen(self, pen_number: int) -> None:
|
||||
if self.current_pen == pen_number:
|
||||
return
|
||||
self.data.append(f"SP{pen_number};".encode())
|
||||
self.current_pen = pen_number
|
||||
|
||||
def set_pen_width(self, width: float) -> None:
|
||||
if self.current_pen_width == width:
|
||||
return
|
||||
self.data.append(f"PW{width:g};".encode()) # pen width in mm
|
||||
self.current_pen_width = width
|
||||
|
||||
def enter_polygon_mode(self, start_point: Vec2) -> None:
|
||||
x = round(start_point.x, self.decimal_places)
|
||||
y = round(start_point.y, self.decimal_places)
|
||||
self.data.append(f"PA;PU{x},{y};PM;".encode())
|
||||
|
||||
def close_current_polygon(self) -> None:
|
||||
self.data.append(b"PM1;")
|
||||
|
||||
def fill_polygon(self) -> None:
|
||||
self.data.append(b"PM2;FP;") # even/odd fill method
|
||||
|
||||
def set_properties(self, properties: BackendProperties) -> None:
|
||||
pen_number = properties.pen
|
||||
pen_color, opacity = self.resolve_pen_color(properties.color)
|
||||
pen_width = self.resolve_pen_width(properties.lineweight)
|
||||
pen_number = self.switch_current_pen(pen_number, pen_color)
|
||||
self.set_pen(pen_number)
|
||||
self.set_pen_width(pen_width)
|
||||
|
||||
def add_polyline_encoded(
|
||||
self, vertices: Iterable[Vec2], properties: BackendProperties
|
||||
):
|
||||
self.set_properties(properties)
|
||||
self.data.append(polyline_encoder(vertices, self.factional_bits, self.base))
|
||||
|
||||
def add_path(self, path: BkPath2d, properties: BackendProperties):
|
||||
if self.curves and path.has_curves:
|
||||
self.set_properties(properties)
|
||||
self.data.append(path_encoder(path, self.decimal_places))
|
||||
else:
|
||||
points = list(path.flattening(MAX_FLATTEN, segments=4))
|
||||
self.add_polyline_encoded(points, properties)
|
||||
|
||||
@staticmethod
|
||||
def resolve_pen_color(color: Color) -> tuple[colors.RGB, float]:
|
||||
rgb = colors.RGB.from_hex(color)
|
||||
if rgb == WHITE:
|
||||
rgb = BLACK
|
||||
return rgb, alpha_to_opacity(color[7:9])
|
||||
|
||||
def resolve_pen_width(self, width: float) -> float:
|
||||
try:
|
||||
return self._stroke_width_cache[width]
|
||||
except KeyError:
|
||||
pass
|
||||
stroke_width = self.fixed_stroke_width
|
||||
policy = self.lineweight_policy
|
||||
if policy == LineweightPolicy.ABSOLUTE:
|
||||
if self.lineweight_scaling:
|
||||
width = max(self.min_lineweight, width) * self.lineweight_scaling
|
||||
else:
|
||||
width = self.min_lineweight
|
||||
stroke_width = round(width, 2) # in mm
|
||||
elif policy == LineweightPolicy.RELATIVE:
|
||||
stroke_width = round(
|
||||
map_lineweight_to_stroke_width(
|
||||
width, self.min_stroke_width, self.max_stroke_width
|
||||
),
|
||||
2,
|
||||
)
|
||||
self._stroke_width_cache[width] = stroke_width
|
||||
return stroke_width
|
||||
|
||||
def set_background(self, color: Color) -> None:
|
||||
# background is always a white paper
|
||||
pass
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
self.add_polyline_encoded([pos], properties)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
self.add_polyline_encoded([start, end], properties)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
lines = list(lines)
|
||||
if len(lines) == 0:
|
||||
return
|
||||
for line in lines:
|
||||
self.add_polyline_encoded(line, properties)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
for sub_path in path.sub_paths():
|
||||
if len(sub_path) == 0:
|
||||
continue
|
||||
self.add_path(sub_path, properties)
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
paths = list(paths)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
self.enter_polygon_mode(paths[0].start)
|
||||
for p in paths:
|
||||
for sub_path in p.sub_paths():
|
||||
if len(sub_path) == 0:
|
||||
continue
|
||||
self.add_path(sub_path, properties)
|
||||
self.close_current_polygon()
|
||||
self.fill_polygon()
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
points2d: list[Vec2] = points.vertices()
|
||||
if points2d:
|
||||
self.enter_polygon_mode(points2d[0])
|
||||
self.add_polyline_encoded(points2d, properties)
|
||||
self.fill_polygon()
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
pass # TODO: not implemented
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
self.lineweight_policy = config.lineweight_policy
|
||||
if config.min_lineweight:
|
||||
# config.min_lineweight in 1/300 inch!
|
||||
min_lineweight_mm = config.min_lineweight * 25.4 / 300
|
||||
self.min_lineweight = max(0.05, min_lineweight_mm)
|
||||
self.lineweight_scaling = config.lineweight_scaling
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def finalize(self) -> None:
|
||||
pass
|
||||
|
||||
def enter_entity(self, entity, properties) -> None:
|
||||
pass
|
||||
|
||||
def exit_entity(self, entity) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def alpha_to_opacity(alpha: str) -> float:
|
||||
# stroke-opacity: 0.0 = transparent; 1.0 = opaque
|
||||
# alpha: "00" = transparent; "ff" = opaque
|
||||
if len(alpha):
|
||||
try:
|
||||
return int(alpha, 16) / 255
|
||||
except ValueError:
|
||||
pass
|
||||
return 1.0
|
||||
|
||||
|
||||
def map_lineweight_to_stroke_width(
|
||||
lineweight: float,
|
||||
min_stroke_width: float,
|
||||
max_stroke_width: float,
|
||||
min_lineweight=0.05, # defined by DXF
|
||||
max_lineweight=2.11, # defined by DXF
|
||||
) -> float:
|
||||
lineweight = max(min(lineweight, max_lineweight), min_lineweight) - min_lineweight
|
||||
factor = (max_stroke_width - min_stroke_width) / (max_lineweight - min_lineweight)
|
||||
return round(min_stroke_width + round(lineweight * factor), 2)
|
||||
|
||||
|
||||
def flatten_path(path: BkPath2d) -> Sequence[Vec2]:
|
||||
points = list(path.flattening(distance=FLATTEN_MAX))
|
||||
return points
|
||||
|
||||
|
||||
def compile_hpgl2(header: Sequence[bytes], commands: Sequence[bytes]) -> bytes:
|
||||
output = bytearray(PRELUDE)
|
||||
output.extend(b"".join(header))
|
||||
output.extend(b"".join(commands))
|
||||
output.extend(EPILOG)
|
||||
return bytes(output)
|
||||
|
||||
|
||||
def pe_encode(value: float, frac_bits: int = 0, base: int = 64) -> bytes:
|
||||
if frac_bits:
|
||||
value *= 1 << frac_bits
|
||||
x = round(value)
|
||||
if x >= 0:
|
||||
x *= 2
|
||||
else:
|
||||
x = abs(x) * 2 + 1
|
||||
|
||||
chars = bytearray()
|
||||
while x >= base:
|
||||
x, r = divmod(x, base)
|
||||
chars.append(63 + r)
|
||||
if base == 64:
|
||||
chars.append(191 + x)
|
||||
else:
|
||||
chars.append(95 + x)
|
||||
return bytes(chars)
|
||||
|
||||
|
||||
def polyline_encoder(vertices: Iterable[Vec2], frac_bits: int, base: int) -> bytes:
|
||||
cmd = b"PE"
|
||||
if base == 32:
|
||||
cmd = b"PE7"
|
||||
if frac_bits:
|
||||
cmd += b">" + pe_encode(frac_bits, 0, base)
|
||||
data = [cmd + b"<="]
|
||||
vertices = list(vertices)
|
||||
# first point as absolute coordinates
|
||||
current = vertices[0]
|
||||
data.append(pe_encode(current.x, frac_bits, base))
|
||||
data.append(pe_encode(current.y, frac_bits, base))
|
||||
for vertex in vertices[1:]:
|
||||
# remaining points as relative coordinates
|
||||
delta = vertex - current
|
||||
data.append(pe_encode(delta.x, frac_bits, base))
|
||||
data.append(pe_encode(delta.y, frac_bits, base))
|
||||
current = vertex
|
||||
data.append(b";")
|
||||
return b"".join(data)
|
||||
|
||||
|
||||
@no_type_check
|
||||
def path_encoder(path: BkPath2d, decimal_places: int | None) -> bytes:
|
||||
# first point as absolute coordinates
|
||||
current = path.start
|
||||
x = round(current.x, decimal_places)
|
||||
y = round(current.y, decimal_places)
|
||||
data = [f"PU;PA{x:g},{y:g};PD;".encode()]
|
||||
prev_command = Command.MOVE_TO
|
||||
if len(path):
|
||||
commands: list[bytes] = []
|
||||
for cmd in path.commands():
|
||||
delta = cmd.end - current
|
||||
xe = round(delta.x, decimal_places)
|
||||
ye = round(delta.y, decimal_places)
|
||||
if cmd.type == Command.LINE_TO:
|
||||
coords = f"{xe:g},{ye:g};".encode()
|
||||
if prev_command == Command.LINE_TO:
|
||||
# extend previous PR command
|
||||
commands[-1] = commands[-1][:-1] + b"," + coords
|
||||
else:
|
||||
commands.append(b"PR" + coords)
|
||||
prev_command = Command.LINE_TO
|
||||
else:
|
||||
if cmd.type == Command.CURVE3_TO:
|
||||
control = cmd.ctrl - current
|
||||
end = cmd.end - current
|
||||
control_1 = 2.0 * control / 3.0
|
||||
control_2 = end + 2.0 * (control - end) / 3.0
|
||||
elif cmd.type == Command.CURVE4_TO:
|
||||
control_1 = cmd.ctrl1 - current
|
||||
control_2 = cmd.ctrl2 - current
|
||||
else:
|
||||
raise ValueError("internal error: MOVE_TO command is illegal here")
|
||||
x1 = round(control_1.x, decimal_places)
|
||||
y1 = round(control_1.y, decimal_places)
|
||||
x2 = round(control_2.x, decimal_places)
|
||||
y2 = round(control_2.y, decimal_places)
|
||||
coords = f"{x1:g},{y1:g},{x2:g},{y2:g},{xe:g},{ye:g};".encode()
|
||||
if prev_command == Command.CURVE4_TO:
|
||||
# extend previous BR command
|
||||
commands[-1] = commands[-1][:-1] + b"," + coords
|
||||
else:
|
||||
commands.append(b"BR" + coords)
|
||||
prev_command = Command.CURVE4_TO
|
||||
current = cmd.end
|
||||
data.append(b"".join(commands))
|
||||
data.append(b"PU;")
|
||||
return b"".join(data)
|
||||
@@ -0,0 +1,590 @@
|
||||
# Copyright (c) 2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence, no_type_check, Any, Callable, Dict, List, Tuple
|
||||
from typing_extensions import TypeAlias, override
|
||||
import abc
|
||||
import json
|
||||
|
||||
from ezdxf.math import Vec2, world_mercator_to_gps
|
||||
from ezdxf.path import Command, nesting
|
||||
from ezdxf.npshapes import orient_paths, single_paths
|
||||
|
||||
from .type_hints import Color
|
||||
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
|
||||
from .config import Configuration
|
||||
from .properties import BackendProperties
|
||||
|
||||
|
||||
__all__ = ["CustomJSONBackend", "GeoJSONBackend"]
|
||||
|
||||
CUSTOM_JSON_SPECS = """
|
||||
JSON content = [entity, entity, ...]
|
||||
|
||||
entity = {
|
||||
"type": point | lines | path | filled-paths | filled-polygon,
|
||||
"properties": {
|
||||
"color": "#RRGGBBAA",
|
||||
"stroke-width": 0.25, # in mm
|
||||
"layer": "name"
|
||||
},
|
||||
"geometry": depends on "type"
|
||||
}
|
||||
DXF linetypes (DASH, DOT, ...) are resolved into solid lines.
|
||||
|
||||
A single point:
|
||||
point = {
|
||||
"type": "point",
|
||||
"properties": {...},
|
||||
"geometry": [x, y]
|
||||
}
|
||||
|
||||
Multiple lines with common properties:
|
||||
lines = {
|
||||
"type": "lines",
|
||||
"properties": {...},
|
||||
"geometry": [
|
||||
(x0, y0, x1, y1), # 1. line
|
||||
(x0, y0, x1, y1), # 2. line
|
||||
....
|
||||
]
|
||||
}
|
||||
Lines can contain points where x0 == x1 and y0 == y1!
|
||||
|
||||
A single linear path without filling:
|
||||
path = {
|
||||
"type": "path",
|
||||
"properties": {...},
|
||||
"geometry": [path-command, ...]
|
||||
}
|
||||
|
||||
SVG-like path structure:
|
||||
- The first path-command is always an absolute move to "M"
|
||||
- The "M" command does not appear inside a path, each path is a continuouse geometry
|
||||
(no multi-paths).
|
||||
|
||||
path-command =
|
||||
("M", x, y) = absolute move to
|
||||
("L", x, y) = absolute line to
|
||||
("Q", x0, y0, x1, y1) = absolute quadratice Bezier curve to
|
||||
- (x0, y0) = control point
|
||||
- (x1, y1) = end point
|
||||
("C", x0, y0, x1, y1, x2, y2) = absolute cubic Bezier curve to
|
||||
- (x0, y0) = control point 1
|
||||
- (x1, y1) = control point 2
|
||||
- (x2, y2) = end point
|
||||
("Z",) = close path
|
||||
|
||||
Multiple filled paths:
|
||||
|
||||
Exterior paths and holes are mixed and NOT oriented by default (clockwise or
|
||||
counter-clockwise) - PyQt and SVG have no problem with that structure but matplotlib
|
||||
requires oriented paths. When oriented paths are required the CustomJSONBackend can
|
||||
orient the paths on demand.
|
||||
|
||||
filled-paths = {
|
||||
"type": "filled-paths",
|
||||
"properties": {...},
|
||||
"geometry": [
|
||||
[path-command, ...], # 1. path
|
||||
[path-command, ...], # 2. path
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
A single filled polygon:
|
||||
A polygon is explicitly closed, so first vertex == last vertex is guaranteed.
|
||||
filled-polygon = {
|
||||
"type": "filled-polygon",
|
||||
"properties": {...},
|
||||
"geometry": [
|
||||
(x0, y0),
|
||||
(x1, y1),
|
||||
(x2, y2),
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class _JSONBackend(BackendInterface):
|
||||
def __init__(self) -> None:
|
||||
self._entities: list[dict[str, Any]] = []
|
||||
self.max_sagitta = 0.01 # set by configure()
|
||||
self.min_lineweight = 0.05 # in mm, set by configure()
|
||||
self.lineweight_scaling = 1.0 # set by configure()
|
||||
# set fixed lineweight for all strokes:
|
||||
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
|
||||
# set Configuration.lineweight_scaling to 0
|
||||
self.fixed_lineweight = 0.0
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_json_data(self) -> Any: ...
|
||||
def get_string(self, *, indent: int | str = 2) -> str:
|
||||
"""Returns the result as a JSON string."""
|
||||
return json.dumps(self.get_json_data(), indent=indent)
|
||||
|
||||
@override
|
||||
def configure(self, config: Configuration) -> None:
|
||||
if config.min_lineweight:
|
||||
# config.min_lineweight in 1/300 inch!
|
||||
min_lineweight_mm = config.min_lineweight * 25.4 / 300
|
||||
self.min_lineweight = max(0.05, min_lineweight_mm)
|
||||
self.lineweight_scaling = config.lineweight_scaling
|
||||
if self.lineweight_scaling == 0.0:
|
||||
# use a fixed lineweight for all strokes defined by min_lineweight
|
||||
self.fixed_lineweight = self.min_lineweight
|
||||
self.max_sagitta = config.max_flattening_distance
|
||||
|
||||
@override
|
||||
def clear(self) -> None:
|
||||
self._entities.clear()
|
||||
|
||||
@override
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
pass
|
||||
|
||||
@override
|
||||
def set_background(self, color: Color) -> None:
|
||||
pass
|
||||
|
||||
@override
|
||||
def finalize(self) -> None:
|
||||
pass
|
||||
|
||||
@override
|
||||
def enter_entity(self, entity, properties) -> None:
|
||||
pass
|
||||
|
||||
@override
|
||||
def exit_entity(self, entity) -> None:
|
||||
pass
|
||||
|
||||
|
||||
MOVE_TO_ABS = "M"
|
||||
LINE_TO_ABS = "L"
|
||||
QUAD_TO_ABS = "Q"
|
||||
CUBIC_TO_ABS = "C"
|
||||
CLOSE_PATH = "Z"
|
||||
|
||||
|
||||
class CustomJSONBackend(_JSONBackend):
|
||||
"""Creates a JSON-like output with a custom JSON scheme. This scheme supports
|
||||
curved shapes by a SVG-path like structure and coordinates are not limited in
|
||||
any way. This backend can be used to send geometries from a web-backend to a
|
||||
frontend.
|
||||
|
||||
The JSON scheme is documented in the source code:
|
||||
|
||||
https://github.com/mozman/ezdxf/blob/master/src/ezdxf/addons/drawing/json.py
|
||||
|
||||
Args:
|
||||
orient_paths: orient exterior and hole paths on demand, exterior paths have
|
||||
counter-clockwise orientation and holes have clockwise orientation.
|
||||
|
||||
**Class Methods**
|
||||
|
||||
.. automethod:: get_json_data
|
||||
|
||||
.. automethod:: get_string
|
||||
|
||||
.. versionadded:: 1.3.0
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, orient_paths=False) -> None:
|
||||
super().__init__()
|
||||
self.orient_paths = orient_paths
|
||||
|
||||
@override
|
||||
def get_json_data(self) -> list[dict[str, Any]]:
|
||||
"""Returns the result as a JSON-like data structure."""
|
||||
return self._entities
|
||||
|
||||
def add_entity(
|
||||
self, entity_type: str, geometry: Sequence[Any], properties: BackendProperties
|
||||
):
|
||||
if not geometry:
|
||||
return
|
||||
self._entities.append(
|
||||
{
|
||||
"type": entity_type,
|
||||
"properties": self.make_properties_dict(properties),
|
||||
"geometry": geometry,
|
||||
}
|
||||
)
|
||||
|
||||
def make_properties_dict(self, properties: BackendProperties) -> dict[str, Any]:
|
||||
if self.fixed_lineweight:
|
||||
stroke_width = self.fixed_lineweight
|
||||
else:
|
||||
stroke_width = max(
|
||||
self.min_lineweight, properties.lineweight * self.lineweight_scaling
|
||||
)
|
||||
return {
|
||||
"color": properties.color,
|
||||
"stroke-width": round(stroke_width, 2),
|
||||
"layer": properties.layer,
|
||||
}
|
||||
|
||||
@override
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
self.add_entity("point", [pos.x, pos.y], properties)
|
||||
|
||||
@override
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
self.add_entity("lines", [(start.x, start.y, end.x, end.y)], properties)
|
||||
|
||||
@override
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
lines = list(lines)
|
||||
if len(lines) == 0:
|
||||
return
|
||||
self.add_entity("lines", [(s.x, s.y, e.x, e.y) for s, e in lines], properties)
|
||||
|
||||
@override
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
self.add_entity("path", make_json_path(path), properties)
|
||||
|
||||
@override
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
paths = list(paths)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
if self.orient_paths:
|
||||
paths = orient_paths(paths) # returns single paths
|
||||
else:
|
||||
# Just single paths allowed, no multi paths!
|
||||
paths = single_paths(paths)
|
||||
json_paths: list[Any] = []
|
||||
for path in paths:
|
||||
if len(path):
|
||||
json_paths.append(make_json_path(path, close=True))
|
||||
if json_paths:
|
||||
self.add_entity("filled-paths", json_paths, properties)
|
||||
|
||||
@override
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
vertices: list[Vec2] = points.vertices()
|
||||
if len(vertices) < 3:
|
||||
return
|
||||
if not vertices[0].isclose(vertices[-1]):
|
||||
vertices.append(vertices[0])
|
||||
self.add_entity("filled-polygon", [(v.x, v.y) for v in vertices], properties)
|
||||
|
||||
|
||||
@no_type_check
|
||||
def make_json_path(path: BkPath2d, close=False) -> list[Any]:
|
||||
if len(path) == 0:
|
||||
return []
|
||||
end: Vec2 = path.start
|
||||
commands: list = [(MOVE_TO_ABS, end.x, end.y)]
|
||||
for cmd in path.commands():
|
||||
end = cmd.end
|
||||
if cmd.type == Command.MOVE_TO:
|
||||
commands.append((MOVE_TO_ABS, end.x, end.y))
|
||||
elif cmd.type == Command.LINE_TO:
|
||||
commands.append((LINE_TO_ABS, end.x, end.y))
|
||||
elif cmd.type == Command.CURVE3_TO:
|
||||
c1 = cmd.ctrl
|
||||
commands.append((QUAD_TO_ABS, c1.x, c1.y, end.x, end.y))
|
||||
elif cmd.type == Command.CURVE4_TO:
|
||||
c1 = cmd.ctrl1
|
||||
c2 = cmd.ctrl2
|
||||
commands.append((CUBIC_TO_ABS, c1.x, c1.y, c2.x, c2.y, end.x, end.y))
|
||||
if close:
|
||||
commands.append(CLOSE_PATH)
|
||||
return commands
|
||||
|
||||
|
||||
# dict and list not allowed here for Python < 3.10
|
||||
PropertiesMaker: TypeAlias = Callable[[str, float, str], Dict[str, Any]]
|
||||
TransformFunc: TypeAlias = Callable[[Vec2], Tuple[float, float]]
|
||||
# GeoJSON ring
|
||||
Ring: TypeAlias = List[Tuple[float, float]]
|
||||
|
||||
# The first ring is the exterior path followed by optional holes, nested polygons are
|
||||
# not supported by the GeoJSON specification.
|
||||
GeoJsonPolygon: TypeAlias = List[Ring]
|
||||
|
||||
|
||||
def properties_maker(color: str, stroke_width: float, layer: str) -> dict[str, Any]:
|
||||
"""Returns the property dict::
|
||||
|
||||
{
|
||||
"color": color,
|
||||
"stroke-width": stroke_width,
|
||||
"layer": layer,
|
||||
}
|
||||
|
||||
Returning an empty dict prevents properties in the GeoJSON output and also avoids
|
||||
wraping entities into "Feature" objects.
|
||||
"""
|
||||
return {
|
||||
"color": color,
|
||||
"stroke-width": round(stroke_width, 2),
|
||||
"layer": layer,
|
||||
}
|
||||
|
||||
|
||||
def no_transform(location: Vec2) -> tuple[float, float]:
|
||||
"""Dummy transformation function. Does not apply any transformations and
|
||||
just returns the input coordinates.
|
||||
"""
|
||||
return (location.x, location.y)
|
||||
|
||||
|
||||
def make_world_mercator_to_gps_function(tol: float = 1e-6) -> TransformFunc:
|
||||
"""Returns a function to transform WGS84 World Mercator `EPSG:3395 <https://epsg.io/3395>`_
|
||||
location given as cartesian 2D coordinates x, y in meters into WGS84 decimal
|
||||
degrees as longitude and latitude `EPSG:4326 <https://epsg.io/4326>`_ as
|
||||
used by GPS.
|
||||
|
||||
Args:
|
||||
tol: accuracy for latitude calculation
|
||||
|
||||
"""
|
||||
|
||||
def _transform(location: Vec2) -> tuple[float, float]:
|
||||
"""Transforms WGS84 World Mercator EPSG:3395 coordinates to WGS84 EPSG:4326."""
|
||||
return world_mercator_to_gps(location.x, location.y, tol)
|
||||
return _transform
|
||||
|
||||
|
||||
class GeoJSONBackend(_JSONBackend):
|
||||
"""Creates a JSON-like output according the `GeoJSON`_ scheme.
|
||||
GeoJSON uses a geographic coordinate reference system, World Geodetic
|
||||
System 1984 `EPSG:4326 <https://epsg.io/4326>`_, and units of decimal degrees.
|
||||
|
||||
- Latitude: -90 to +90 (South/North)
|
||||
- Longitude: -180 to +180 (East/West)
|
||||
|
||||
So most DXF files will produce invalid coordinates and it is the job of the
|
||||
**package-user** to provide a function to transfrom the input coordinates to
|
||||
EPSG:4326! The :class:`~ezdxf.addons.drawing.recorder.Recorder` and
|
||||
:class:`~ezdxf.addons.drawing.recorder.Player` classes can help to detect the
|
||||
extents of the DXF content.
|
||||
|
||||
Default implementation:
|
||||
|
||||
.. autofunction:: no_transform
|
||||
|
||||
Factory function to make a transform function from WGS84 World Mercator
|
||||
`EPSG:3395 <https://epsg.io/3395>`_ coordinates to WGS84 (GPS)
|
||||
`EPSG:4326 <https://epsg.io/4326>`_.
|
||||
|
||||
.. autofunction:: make_world_mercator_to_gps_function
|
||||
|
||||
The GeoJSON format supports only straight lines so curved shapes are flattened to
|
||||
polylines and polygons.
|
||||
|
||||
The properties are handled as a foreign member feature and is therefore not defined
|
||||
in the GeoJSON specs. It is possible to provide a custom function to create these
|
||||
property objects.
|
||||
|
||||
Default implementation:
|
||||
|
||||
.. autofunction:: properties_maker
|
||||
|
||||
|
||||
Args:
|
||||
properties_maker: function to create a properties dict.
|
||||
|
||||
**Class Methods**
|
||||
|
||||
.. automethod:: get_json_data
|
||||
|
||||
.. automethod:: get_string
|
||||
|
||||
.. versionadded:: 1.3.0
|
||||
|
||||
.. _GeoJSON: https://geojson.org/
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
properties_maker: PropertiesMaker = properties_maker,
|
||||
transform_func: TransformFunc = no_transform,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._properties_dict_maker = properties_maker
|
||||
self._transform_function = transform_func
|
||||
|
||||
@override
|
||||
def get_json_data(self) -> dict[str, Any]:
|
||||
"""Returns the result as a JSON-like data structure according the GeoJSON specs."""
|
||||
if len(self._entities) == 0:
|
||||
return {}
|
||||
using_features = self._entities[0]["type"] == "Feature"
|
||||
if using_features:
|
||||
return {"type": "FeatureCollection", "features": self._entities}
|
||||
else:
|
||||
return {"type": "GeometryCollection", "geometries": self._entities}
|
||||
|
||||
def add_entity(self, entity: dict[str, Any], properties: BackendProperties):
|
||||
if not entity:
|
||||
return
|
||||
properties_dict: dict[str, Any] = self._properties_dict_maker(
|
||||
*self.make_properties(properties)
|
||||
)
|
||||
if properties_dict:
|
||||
self._entities.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": properties_dict,
|
||||
"geometry": entity,
|
||||
}
|
||||
)
|
||||
else:
|
||||
self._entities.append(entity)
|
||||
|
||||
def make_properties(self, properties: BackendProperties) -> tuple[str, float, str]:
|
||||
if self.fixed_lineweight:
|
||||
stroke_width = self.fixed_lineweight
|
||||
else:
|
||||
stroke_width = max(
|
||||
self.min_lineweight, properties.lineweight * self.lineweight_scaling
|
||||
)
|
||||
return (properties.color, round(stroke_width, 2), properties.layer)
|
||||
|
||||
@override
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
self.add_entity(
|
||||
geojson_object("Point", list(self._transform_function(pos))), properties
|
||||
)
|
||||
|
||||
@override
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
tf = self._transform_function
|
||||
self.add_entity(
|
||||
geojson_object("LineString", [tf(start), tf(end)]),
|
||||
properties,
|
||||
)
|
||||
|
||||
@override
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
lines = list(lines)
|
||||
if len(lines) == 0:
|
||||
return
|
||||
tf = self._transform_function
|
||||
json_lines = [(tf(s), tf(e)) for s, e in lines]
|
||||
self.add_entity(geojson_object("MultiLineString", json_lines), properties)
|
||||
|
||||
@override
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
if len(path) == 0:
|
||||
return
|
||||
tf = self._transform_function
|
||||
vertices = [tf(v) for v in path.flattening(distance=self.max_sagitta)]
|
||||
self.add_entity(geojson_object("LineString", vertices), properties)
|
||||
|
||||
@override
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
paths = list(paths)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
|
||||
polygons: list[GeoJsonPolygon] = []
|
||||
for path in paths:
|
||||
if len(path):
|
||||
polygons.extend(
|
||||
geojson_polygons(
|
||||
path, max_sagitta=self.max_sagitta, tf=self._transform_function
|
||||
)
|
||||
)
|
||||
if polygons:
|
||||
self.add_entity(geojson_object("MultiPolygon", polygons), properties)
|
||||
|
||||
@override
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
vertices: list[Vec2] = points.vertices()
|
||||
if len(vertices) < 3:
|
||||
return
|
||||
if not vertices[0].isclose(vertices[-1]):
|
||||
vertices.append(vertices[0])
|
||||
# exterior ring, without holes
|
||||
tf = self._transform_function
|
||||
self.add_entity(
|
||||
geojson_object("Polygon", [[tf(v) for v in vertices]]), properties
|
||||
)
|
||||
|
||||
|
||||
def geojson_object(name: str, coordinates: Any) -> dict[str, Any]:
|
||||
return {"type": name, "coordinates": coordinates}
|
||||
|
||||
|
||||
def geojson_ring(
|
||||
path: BkPath2d, is_hole: bool, max_sagitta: float, tf: TransformFunc
|
||||
) -> Ring:
|
||||
"""Returns a linear ring according to the GeoJSON specs.
|
||||
|
||||
- A linear ring is a closed LineString with four or more positions.
|
||||
- The first and last positions are equivalent, and they MUST contain
|
||||
identical values; their representation SHOULD also be identical.
|
||||
- A linear ring is the boundary of a surface or the boundary of a
|
||||
hole in a surface.
|
||||
- A linear ring MUST follow the right-hand rule with respect to the
|
||||
area it bounds, i.e., exterior rings are counterclockwise, and
|
||||
holes are clockwise.
|
||||
|
||||
"""
|
||||
if path.has_sub_paths:
|
||||
raise TypeError("multi-paths not allowed")
|
||||
vertices: Ring = [tf(v) for v in path.flattening(max_sagitta)]
|
||||
if not path.is_closed:
|
||||
start = path.start
|
||||
vertices.append(tf(start))
|
||||
clockwise = path.has_clockwise_orientation()
|
||||
if (is_hole and not clockwise) or (not is_hole and clockwise):
|
||||
vertices.reverse()
|
||||
return vertices
|
||||
|
||||
|
||||
def geojson_polygons(
|
||||
path: BkPath2d, max_sagitta: float, tf: TransformFunc
|
||||
) -> list[GeoJsonPolygon]:
|
||||
"""Returns a list of polygons, where each polygon is a list of an exterior path and
|
||||
optional holes e.g. [[ext0, hole0, hole1], [ext1], [ext2, hole0], ...].
|
||||
|
||||
"""
|
||||
sub_paths: list[BkPath2d] = path.sub_paths()
|
||||
if len(sub_paths) == 0:
|
||||
return []
|
||||
if len(sub_paths) == 1:
|
||||
return [[geojson_ring(sub_paths[0], False, max_sagitta, tf)]]
|
||||
|
||||
polygons = nesting.make_polygon_structure(sub_paths)
|
||||
geojson_polygons: list[GeoJsonPolygon] = []
|
||||
for polygon in polygons:
|
||||
geojson_polygon: GeoJsonPolygon = [
|
||||
geojson_ring(polygon[0], False, max_sagitta, tf)
|
||||
] # exterior ring
|
||||
if len(polygon) > 1:
|
||||
# GeoJSON has no support for nested hole structures, so the sub polygons of
|
||||
# holes (hole[1]) are ignored yet!
|
||||
holes = polygon[1]
|
||||
if isinstance(holes, BkPath2d): # single hole
|
||||
geojson_polygon.append(geojson_ring(holes, True, max_sagitta, tf))
|
||||
continue
|
||||
if isinstance(holes, (tuple, list)): # multiple holes
|
||||
for hole in holes:
|
||||
if isinstance(hole, (tuple, list)): # nested polygon
|
||||
# TODO: add sub polygons of holes as separated polygons
|
||||
hole = hole[0] # exterior path
|
||||
geojson_polygon.append(geojson_ring(hole, True, max_sagitta, tf))
|
||||
|
||||
geojson_polygons.append(geojson_polygon)
|
||||
return geojson_polygons
|
||||
@@ -0,0 +1,561 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import NamedTuple, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
|
||||
import math
|
||||
import enum
|
||||
import dataclasses
|
||||
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.layouts.layout import Layout as DXFLayout
|
||||
|
||||
|
||||
class Units(enum.IntEnum):
|
||||
"""Page units as enum.
|
||||
|
||||
Attributes:
|
||||
inch: 25.4 mm
|
||||
px: 1/96 inch
|
||||
pt: 1/72 inch
|
||||
mm:
|
||||
cm:
|
||||
|
||||
"""
|
||||
|
||||
# equivalent to ezdxf.units if possible
|
||||
inch = 1
|
||||
px = 2 # no equivalent DXF unit
|
||||
pt = 3 # no equivalent DXF unit
|
||||
mm = 4
|
||||
cm = 5
|
||||
|
||||
|
||||
# all page sizes in landscape orientation
|
||||
PAGE_SIZES = {
|
||||
"ISO A0": (1189, 841, Units.mm),
|
||||
"ISO A1": (841, 594, Units.mm),
|
||||
"ISO A2": (594, 420, Units.mm),
|
||||
"ISO A3": (420, 297, Units.mm),
|
||||
"ISO A4": (297, 210, Units.mm),
|
||||
"ANSI A": (11, 8.5, Units.inch),
|
||||
"ANSI B": (17, 11, Units.inch),
|
||||
"ANSI C": (22, 17, Units.inch),
|
||||
"ANSI D": (34, 22, Units.inch),
|
||||
"ANSI E": (44, 34, Units.inch),
|
||||
"ARCH C": (24, 18, Units.inch),
|
||||
"ARCH D": (36, 24, Units.inch),
|
||||
"ARCH E": (48, 36, Units.inch),
|
||||
"ARCH E1": (42, 30, Units.inch),
|
||||
"Letter": (11, 8.5, Units.inch),
|
||||
"Legal": (14, 8.5, Units.inch),
|
||||
}
|
||||
|
||||
|
||||
UNITS_TO_MM = {
|
||||
Units.mm: 1.0,
|
||||
Units.cm: 10.0,
|
||||
Units.inch: 25.4,
|
||||
Units.px: 25.4 / 96.0,
|
||||
Units.pt: 25.4 / 72.0,
|
||||
}
|
||||
|
||||
|
||||
class PageAlignment(enum.IntEnum):
|
||||
"""Page alignment of content as enum.
|
||||
|
||||
Attributes:
|
||||
TOP_LEFT:
|
||||
TOP_CENTER:
|
||||
TOP_RIGHT:
|
||||
MIDDLE_LEFT:
|
||||
MIDDLE_CENTER:
|
||||
MIDDLE_RIGHT:
|
||||
BOTTOM_LEFT:
|
||||
BOTTOM_CENTER:
|
||||
BOTTOM_RIGHT:
|
||||
|
||||
"""
|
||||
|
||||
TOP_LEFT = 1
|
||||
TOP_CENTER = 2
|
||||
TOP_RIGHT = 3
|
||||
MIDDLE_LEFT = 4
|
||||
MIDDLE_CENTER = 5
|
||||
MIDDLE_RIGHT = 6
|
||||
BOTTOM_LEFT = 7
|
||||
BOTTOM_CENTER = 8
|
||||
BOTTOM_RIGHT = 9
|
||||
|
||||
|
||||
class Margins(NamedTuple):
|
||||
"""Page margins definition class
|
||||
|
||||
Attributes:
|
||||
top:
|
||||
left:
|
||||
bottom:
|
||||
right:
|
||||
|
||||
"""
|
||||
|
||||
top: float
|
||||
right: float
|
||||
bottom: float
|
||||
left: float
|
||||
|
||||
@classmethod
|
||||
def all(cls, margin: float) -> Self:
|
||||
"""Returns a page margins definition class with four equal margins."""
|
||||
return cls(margin, margin, margin, margin)
|
||||
|
||||
@classmethod
|
||||
def all2(cls, top_bottom: float, left_right: float) -> Self:
|
||||
"""Returns a page margins definition class with equal top-bottom and
|
||||
left-right margins.
|
||||
"""
|
||||
return cls(top_bottom, left_right, top_bottom, left_right)
|
||||
|
||||
# noinspection PyArgumentList
|
||||
def scale(self, factor: float) -> Self:
|
||||
return self.__class__(
|
||||
self.top * factor,
|
||||
self.right * factor,
|
||||
self.bottom * factor,
|
||||
self.left * factor,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Page:
|
||||
"""Page definition class
|
||||
|
||||
Attributes:
|
||||
|
||||
width: page width, 0 for auto-detect
|
||||
height: page height, 0 for auto-detect
|
||||
units: page units as enum :class:`Units`
|
||||
margins: page margins in page units
|
||||
max_width: limit width for auto-detection, 0 for unlimited
|
||||
max_height: limit height for auto-detection, 0 for unlimited
|
||||
|
||||
"""
|
||||
|
||||
width: float
|
||||
height: float
|
||||
units: Units = Units.mm
|
||||
margins: Margins = Margins.all(0)
|
||||
max_width: float = 0.0
|
||||
max_height: float = 0.0
|
||||
|
||||
def __post_init__(self):
|
||||
assert isinstance(self.units, Units), "units require type <Units>"
|
||||
assert isinstance(self.margins, Margins), "margins require type <Margins>"
|
||||
|
||||
@property
|
||||
def to_mm_factor(self) -> float:
|
||||
return UNITS_TO_MM[self.units]
|
||||
|
||||
@property
|
||||
def width_in_mm(self) -> float:
|
||||
"""Returns the page width in mm."""
|
||||
return round(self.width * self.to_mm_factor, 1)
|
||||
|
||||
@property
|
||||
def max_width_in_mm(self) -> float:
|
||||
"""Returns max page width in mm."""
|
||||
return round(self.max_width * self.to_mm_factor, 1)
|
||||
|
||||
@property
|
||||
def height_in_mm(self) -> float:
|
||||
"""Returns the page height in mm."""
|
||||
return round(self.height * self.to_mm_factor, 1)
|
||||
|
||||
@property
|
||||
def max_height_in_mm(self) -> float:
|
||||
"""Returns max page height in mm."""
|
||||
return round(self.max_height * self.to_mm_factor, 1)
|
||||
|
||||
@property
|
||||
def margins_in_mm(self) -> Margins:
|
||||
"""Returns the page margins in mm."""
|
||||
return self.margins.scale(self.to_mm_factor)
|
||||
|
||||
@property
|
||||
def is_landscape(self) -> bool:
|
||||
"""Returns ``True`` if the page has landscape orientation."""
|
||||
return self.width > self.height
|
||||
|
||||
@property
|
||||
def is_portrait(self) -> bool:
|
||||
"""Returns ``True`` if the page has portrait orientation. (square is portrait)"""
|
||||
return self.width <= self.height
|
||||
|
||||
def to_landscape(self) -> None:
|
||||
"""Converts the page to landscape orientation."""
|
||||
if self.is_portrait:
|
||||
self.width, self.height = self.height, self.width
|
||||
|
||||
def to_portrait(self) -> None:
|
||||
"""Converts the page to portrait orientation."""
|
||||
if self.is_landscape:
|
||||
self.width, self.height = self.height, self.width
|
||||
|
||||
def get_margin_rect(self, top_origin=True) -> tuple[Vec2, Vec2]:
|
||||
"""Returns the bottom-left and the top-right corner of the page margins in mm.
|
||||
The origin (0, 0) is the top-left corner of the page if `top_origin` is
|
||||
``True`` or in the bottom-left corner otherwise.
|
||||
"""
|
||||
margins = self.margins_in_mm
|
||||
right_margin = self.width_in_mm - margins.right
|
||||
page_height = self.height_in_mm
|
||||
if top_origin:
|
||||
bottom_left = Vec2(margins.left, margins.top)
|
||||
top_right = Vec2(right_margin, page_height - margins.bottom)
|
||||
else: # bottom origin
|
||||
bottom_left = Vec2(margins.left, margins.bottom)
|
||||
top_right = Vec2(right_margin, page_height - margins.top)
|
||||
return bottom_left, top_right
|
||||
|
||||
@classmethod
|
||||
def from_dxf_layout(cls, layout: DXFLayout) -> Self:
|
||||
"""Returns the :class:`Page` based on the DXF attributes stored in the LAYOUT
|
||||
entity. The modelspace layout often **doesn't** have usable page settings!
|
||||
|
||||
Args:
|
||||
layout: any paperspace layout or the modelspace layout
|
||||
|
||||
"""
|
||||
# all layout measurements in mm
|
||||
width = round(layout.dxf.paper_width, 1)
|
||||
height = round(layout.dxf.paper_height, 1)
|
||||
top = round(layout.dxf.top_margin, 1)
|
||||
right = round(layout.dxf.right_margin, 1)
|
||||
bottom = round(layout.dxf.bottom_margin, 1)
|
||||
left = round(layout.dxf.left_margin, 1)
|
||||
|
||||
rotation = layout.dxf.plot_rotation
|
||||
if rotation == 1: # 90 degrees
|
||||
return cls(
|
||||
height,
|
||||
width,
|
||||
Units.mm,
|
||||
margins=Margins(top=right, right=bottom, bottom=left, left=top),
|
||||
)
|
||||
elif rotation == 2: # 180 degrees
|
||||
return cls(
|
||||
width,
|
||||
height,
|
||||
Units.mm,
|
||||
margins=Margins(top=bottom, right=left, bottom=top, left=right),
|
||||
)
|
||||
elif rotation == 3: # 270 degrees
|
||||
return cls(
|
||||
height,
|
||||
width,
|
||||
Units.mm,
|
||||
margins=Margins(top=left, right=top, bottom=right, left=bottom),
|
||||
)
|
||||
return cls( # 0 degrees
|
||||
width,
|
||||
height,
|
||||
Units.mm,
|
||||
margins=Margins(top=top, right=right, bottom=bottom, left=left),
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Settings:
|
||||
"""The Layout settings.
|
||||
|
||||
Attributes:
|
||||
content_rotation: Rotate content about 0, 90, 180 or 270 degrees
|
||||
fit_page: Scale content to fit the page.
|
||||
page_alignment: Supported by backends that use the :class:`Page` class to define
|
||||
the size of the output media, default alignment is :attr:`PageAlignment.MIDDLE_CENTER`
|
||||
crop_at_margins: crops the content at the page margins if ``True``, when
|
||||
supported by the backend, default is ``False``
|
||||
scale: Factor to scale the DXF units of model- or paperspace, to represent 1mm
|
||||
in the rendered output drawing. Only uniform scaling is supported.
|
||||
|
||||
e.g. scale 1:100 and DXF units are meters, 1m = 1000mm corresponds 10mm in
|
||||
the output drawing = 10 / 1000 = 0.01;
|
||||
|
||||
e.g. scale 1:1; DXF units are mm = 1 / 1 = 1.0 the default value
|
||||
|
||||
The value is ignored if the page size is defined and the content fits the page and
|
||||
the value is also used to determine missing page sizes (width or height).
|
||||
max_stroke_width: Used for :class:`LineweightPolicy.RELATIVE` policy,
|
||||
:attr:`max_stroke_width` is defined as percentage of the content extents,
|
||||
e.g. 0.001 is 0.1% of max(page-width, page-height)
|
||||
min_stroke_width: Used for :class:`LineweightPolicy.RELATIVE` policy,
|
||||
:attr:`min_stroke_width` is defined as percentage of :attr:`max_stroke_width`,
|
||||
e.g. 0.05 is 5% of :attr:`max_stroke_width`
|
||||
fixed_stroke_width: Used for :class:`LineweightPolicy.RELATIVE_FIXED` policy,
|
||||
:attr:`fixed_stroke_width` is defined as percentage of :attr:`max_stroke_width`,
|
||||
e.g. 0.15 is 15% of :attr:`max_stroke_width`
|
||||
output_coordinate_space: expert feature to map the DXF coordinates to the
|
||||
output coordinate system [0, output_coordinate_space]
|
||||
output_layers: For supported backends, separate the entities into 'layers' in the output
|
||||
|
||||
"""
|
||||
|
||||
content_rotation: int = 0
|
||||
fit_page: bool = True
|
||||
scale: float = 1.0
|
||||
page_alignment: PageAlignment = PageAlignment.MIDDLE_CENTER
|
||||
crop_at_margins: bool = False
|
||||
# for LineweightPolicy.RELATIVE
|
||||
# max_stroke_width is defined as percentage of the content extents
|
||||
max_stroke_width: float = 0.001 # 0.1% of max(width, height) in viewBox coords
|
||||
# min_stroke_width is defined as percentage of max_stroke_width
|
||||
min_stroke_width: float = 0.05 # 5% of max_stroke_width
|
||||
# StrokeWidthPolicy.fixed_1
|
||||
# fixed_stroke_width is defined as percentage of max_stroke_width
|
||||
fixed_stroke_width: float = 0.15 # 15% of max_stroke_width
|
||||
# PDF, HPGL expect the coordinates in the first quadrant and SVG has an inverted
|
||||
# y-axis, so transformation from DXF to the output coordinate system is required.
|
||||
# The output_coordinate_space defines the space into which the DXF coordinates are
|
||||
# mapped, the range is [0, output_coordinate_space] for the larger page
|
||||
# dimension - aspect ratio is always preserved - these are CAD drawings!
|
||||
# The SVGBackend uses this feature to map all coordinates to integer values:
|
||||
output_coordinate_space: float = 1_000_000 # e.g. for SVGBackend
|
||||
output_layers: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.content_rotation not in (0, 90, 180, 270):
|
||||
raise ValueError(
|
||||
f"invalid content rotation {self.content_rotation}, "
|
||||
f"expected: 0, 90, 180, 270"
|
||||
)
|
||||
|
||||
def page_output_scale_factor(self, page: Page) -> float:
|
||||
"""Returns the scaling factor to map page coordinates in mm to output space
|
||||
coordinates.
|
||||
"""
|
||||
try:
|
||||
return self.output_coordinate_space / max(
|
||||
page.width_in_mm, page.height_in_mm
|
||||
)
|
||||
except ZeroDivisionError:
|
||||
return 1.0
|
||||
|
||||
|
||||
class Layout:
|
||||
def __init__(self, render_box: BoundingBox2d, flip_y=False) -> None:
|
||||
super().__init__()
|
||||
self.flip_y: float = -1.0 if flip_y else 1.0
|
||||
self.render_box = render_box
|
||||
|
||||
def get_rotation(self, settings: Settings) -> int:
|
||||
if settings.content_rotation not in (0, 90, 180, 270):
|
||||
raise ValueError("content rotation must be 0, 90, 180 or 270 degrees")
|
||||
rotation = settings.content_rotation
|
||||
if self.flip_y == -1.0:
|
||||
if rotation == 90:
|
||||
rotation = 270
|
||||
elif rotation == 270:
|
||||
rotation = 90
|
||||
return rotation
|
||||
|
||||
def get_content_size(self, rotation: int) -> Vec2:
|
||||
content_size = self.render_box.size
|
||||
if rotation in (90, 270):
|
||||
# swap x, y to apply rotation to content_size
|
||||
content_size = Vec2(content_size.y, content_size.x)
|
||||
return content_size
|
||||
|
||||
def get_final_page(self, page: Page, settings: Settings) -> Page:
|
||||
rotation = self.get_rotation(settings)
|
||||
content_size = self.get_content_size(rotation)
|
||||
return final_page_size(content_size, page, settings)
|
||||
|
||||
def get_placement_matrix(
|
||||
self, page: Page, settings=Settings(), top_origin=True
|
||||
) -> Matrix44:
|
||||
# Argument `page` has to be the resolved final page size!
|
||||
rotation = self.get_rotation(settings)
|
||||
|
||||
content_size = self.get_content_size(rotation)
|
||||
content_size_mm = content_size * settings.scale
|
||||
if settings.fit_page:
|
||||
content_size_mm *= fit_to_page(content_size_mm, page)
|
||||
try:
|
||||
scale_dxf_to_mm = content_size_mm.x / content_size.x
|
||||
except ZeroDivisionError:
|
||||
scale_dxf_to_mm = 1.0
|
||||
# map output coordinates to range [0, output_coordinate_space]
|
||||
scale_mm_to_output_space = settings.page_output_scale_factor(page)
|
||||
scale = scale_dxf_to_mm * scale_mm_to_output_space
|
||||
m = placement_matrix(
|
||||
self.render_box,
|
||||
sx=scale,
|
||||
sy=scale * self.flip_y,
|
||||
rotation=rotation,
|
||||
page=page,
|
||||
output_coordinate_space=settings.output_coordinate_space,
|
||||
page_alignment=settings.page_alignment,
|
||||
top_origin=top_origin,
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
def final_page_size(content_size: Vec2, page: Page, settings: Settings) -> Page:
|
||||
scale = settings.scale
|
||||
width = page.width_in_mm
|
||||
height = page.height_in_mm
|
||||
margins = page.margins_in_mm
|
||||
if width == 0.0:
|
||||
width = scale * content_size.x + margins.left + margins.right
|
||||
if height == 0.0:
|
||||
height = scale * content_size.y + margins.top + margins.bottom
|
||||
|
||||
width, height = limit_page_size(
|
||||
width, height, page.max_width_in_mm, page.max_height_in_mm
|
||||
)
|
||||
return Page(round(width, 1), round(height, 1), Units.mm, margins)
|
||||
|
||||
|
||||
def limit_page_size(
|
||||
width: float, height: float, max_width: float, max_height: float
|
||||
) -> tuple[float, float]:
|
||||
try:
|
||||
ar = width / height
|
||||
except ZeroDivisionError:
|
||||
return width, height
|
||||
if max_height:
|
||||
height = min(max_height, height)
|
||||
width = height * ar
|
||||
if max_width and width > max_width:
|
||||
width = min(max_width, width)
|
||||
height = width / ar
|
||||
return width, height
|
||||
|
||||
|
||||
def fit_to_page(content_size_mm: Vec2, page: Page) -> float:
|
||||
margins = page.margins_in_mm
|
||||
try:
|
||||
sx = (page.width_in_mm - margins.left - margins.right) / content_size_mm.x
|
||||
sy = (page.height_in_mm - margins.top - margins.bottom) / content_size_mm.y
|
||||
except ZeroDivisionError:
|
||||
return 1.0
|
||||
return min(sx, sy)
|
||||
|
||||
|
||||
def placement_matrix(
|
||||
bbox: BoundingBox2d,
|
||||
sx: float,
|
||||
sy: float,
|
||||
rotation: float,
|
||||
page: Page,
|
||||
output_coordinate_space: float,
|
||||
page_alignment: PageAlignment = PageAlignment.MIDDLE_CENTER,
|
||||
# top_origin True: page origin (0, 0) in top-left corner, +y axis pointing down
|
||||
# top_origin False: page origin (0, 0) in bottom-left corner, +y axis pointing up
|
||||
top_origin=True,
|
||||
) -> Matrix44:
|
||||
"""Returns a matrix to place the bbox in the first quadrant of the coordinate
|
||||
system (+x, +y).
|
||||
"""
|
||||
try:
|
||||
scale_mm_to_vb = output_coordinate_space / max(
|
||||
page.width_in_mm, page.height_in_mm
|
||||
)
|
||||
except ZeroDivisionError:
|
||||
scale_mm_to_vb = 1.0
|
||||
margins = page.margins_in_mm
|
||||
|
||||
# create scaling and rotation matrix:
|
||||
if abs(sx) < 1e-9:
|
||||
sx = 1.0
|
||||
if abs(sy) < 1e-9:
|
||||
sy = 1.0
|
||||
m = Matrix44.scale(sx, sy, 1.0)
|
||||
if rotation:
|
||||
m @= Matrix44.z_rotate(math.radians(rotation))
|
||||
|
||||
# calc bounding box of the final output canvas:
|
||||
corners = m.transform_vertices(bbox.rect_vertices())
|
||||
canvas = BoundingBox2d(corners)
|
||||
|
||||
# shift content to first quadrant +x/+y
|
||||
tx, ty = canvas.extmin
|
||||
|
||||
# align content within margins
|
||||
view_box_content_x = (
|
||||
page.width_in_mm - margins.left - margins.right
|
||||
) * scale_mm_to_vb
|
||||
view_box_content_y = (
|
||||
page.height_in_mm - margins.top - margins.bottom
|
||||
) * scale_mm_to_vb
|
||||
dx = view_box_content_x - canvas.size.x
|
||||
dy = view_box_content_y - canvas.size.y
|
||||
offset_x = margins.left * scale_mm_to_vb # left
|
||||
if top_origin:
|
||||
offset_y = margins.top * scale_mm_to_vb
|
||||
else:
|
||||
offset_y = margins.bottom * scale_mm_to_vb
|
||||
|
||||
if is_center_aligned(page_alignment):
|
||||
offset_x += dx / 2
|
||||
elif is_right_aligned(page_alignment):
|
||||
offset_x += dx
|
||||
if is_middle_aligned(page_alignment):
|
||||
offset_y += dy / 2
|
||||
elif is_bottom_aligned(page_alignment):
|
||||
if top_origin:
|
||||
offset_y += dy
|
||||
else: # top aligned
|
||||
if not top_origin:
|
||||
offset_y += dy
|
||||
return m @ Matrix44.translate(-tx + offset_x, -ty + offset_y, 0)
|
||||
|
||||
|
||||
def is_left_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.TOP_LEFT,
|
||||
PageAlignment.MIDDLE_LEFT,
|
||||
PageAlignment.BOTTOM_LEFT,
|
||||
)
|
||||
|
||||
|
||||
def is_center_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.TOP_CENTER,
|
||||
PageAlignment.MIDDLE_CENTER,
|
||||
PageAlignment.BOTTOM_CENTER,
|
||||
)
|
||||
|
||||
|
||||
def is_right_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.TOP_RIGHT,
|
||||
PageAlignment.MIDDLE_RIGHT,
|
||||
PageAlignment.BOTTOM_RIGHT,
|
||||
)
|
||||
|
||||
|
||||
def is_top_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.TOP_LEFT,
|
||||
PageAlignment.TOP_CENTER,
|
||||
PageAlignment.TOP_RIGHT,
|
||||
)
|
||||
|
||||
|
||||
def is_middle_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.MIDDLE_LEFT,
|
||||
PageAlignment.MIDDLE_CENTER,
|
||||
PageAlignment.MIDDLE_RIGHT,
|
||||
)
|
||||
|
||||
|
||||
def is_bottom_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.BOTTOM_LEFT,
|
||||
PageAlignment.BOTTOM_CENTER,
|
||||
PageAlignment.BOTTOM_RIGHT,
|
||||
)
|
||||
@@ -0,0 +1,361 @@
|
||||
# Copyright (c) 2020-2023, Matthew Broadway
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Optional, Union
|
||||
import math
|
||||
import logging
|
||||
from os import PathLike
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from matplotlib.collections import LineCollection
|
||||
from matplotlib.image import AxesImage
|
||||
from matplotlib.lines import Line2D
|
||||
from matplotlib.patches import PathPatch
|
||||
from matplotlib.path import Path
|
||||
from matplotlib.transforms import Affine2D
|
||||
|
||||
from ezdxf.npshapes import to_matplotlib_path
|
||||
from ezdxf.addons.drawing.backend import Backend, BkPath2d, BkPoints2d, ImageData
|
||||
from ezdxf.addons.drawing.properties import BackendProperties, LayoutProperties
|
||||
from ezdxf.addons.drawing.type_hints import FilterFunc
|
||||
from ezdxf.addons.drawing.type_hints import Color
|
||||
from ezdxf.math import Vec2, Matrix44
|
||||
from ezdxf.layouts import Layout
|
||||
from .config import Configuration
|
||||
|
||||
logger = logging.getLogger("ezdxf")
|
||||
# matplotlib docs: https://matplotlib.org/index.html
|
||||
|
||||
# line style:
|
||||
# https://matplotlib.org/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D.set_linestyle
|
||||
# https://matplotlib.org/gallery/lines_bars_and_markers/linestyles.html
|
||||
|
||||
# line width:
|
||||
# https://matplotlib.org/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D.set_linewidth
|
||||
# points unit (pt), 1pt = 1/72 inch, 1pt = 0.3527mm
|
||||
POINTS = 1.0 / 0.3527 # mm -> points
|
||||
CURVE4x3 = (Path.CURVE4, Path.CURVE4, Path.CURVE4)
|
||||
SCATTER_POINT_SIZE = 0.1
|
||||
|
||||
|
||||
def setup_axes(ax: plt.Axes):
|
||||
# like set_axis_off, except that the face_color can still be set
|
||||
ax.xaxis.set_visible(False)
|
||||
ax.yaxis.set_visible(False)
|
||||
for s in ax.spines.values():
|
||||
s.set_visible(False)
|
||||
|
||||
ax.autoscale(False)
|
||||
ax.set_aspect("equal", "datalim")
|
||||
|
||||
|
||||
class MatplotlibBackend(Backend):
|
||||
"""Backend which uses the :mod:`Matplotlib` package for image export.
|
||||
|
||||
Args:
|
||||
ax: drawing canvas as :class:`matplotlib.pyplot.Axes` object
|
||||
adjust_figure: automatically adjust the size of the parent
|
||||
:class:`matplotlib.pyplot.Figure` to display all content
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ax: plt.Axes,
|
||||
*,
|
||||
adjust_figure: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
setup_axes(ax)
|
||||
self.ax = ax
|
||||
self._adjust_figure = adjust_figure
|
||||
self._current_z = 0
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
if config.min_lineweight is None:
|
||||
# If not set by user, use ~1 pixel
|
||||
figure = self.ax.get_figure()
|
||||
if figure:
|
||||
config = config.with_changes(min_lineweight=72.0 / figure.dpi)
|
||||
super().configure(config)
|
||||
# LinePolicy.ACCURATE is handled by the frontend since v0.18.1
|
||||
|
||||
def _get_z(self) -> int:
|
||||
z = self._current_z
|
||||
self._current_z += 1
|
||||
return z
|
||||
|
||||
def set_background(self, color: Color):
|
||||
self.ax.set_facecolor(color)
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties):
|
||||
"""Draw a real dimensionless point."""
|
||||
color = properties.color
|
||||
self.ax.scatter(
|
||||
[pos.x],
|
||||
[pos.y],
|
||||
s=SCATTER_POINT_SIZE,
|
||||
c=color,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
|
||||
def get_lineweight(self, properties: BackendProperties) -> float:
|
||||
"""Set lineweight_scaling=0 to use a constant minimal lineweight."""
|
||||
assert self.config.min_lineweight is not None
|
||||
return max(
|
||||
properties.lineweight * self.config.lineweight_scaling,
|
||||
self.config.min_lineweight,
|
||||
)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties):
|
||||
"""Draws a single solid line, line type rendering is done by the
|
||||
frontend since v0.18.1
|
||||
"""
|
||||
if start.isclose(end):
|
||||
# matplotlib draws nothing for a zero-length line:
|
||||
self.draw_point(start, properties)
|
||||
else:
|
||||
self.ax.add_line(
|
||||
Line2D(
|
||||
(start.x, end.x),
|
||||
(start.y, end.y),
|
||||
linewidth=self.get_lineweight(properties),
|
||||
color=properties.color,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
)
|
||||
|
||||
def draw_solid_lines(
|
||||
self,
|
||||
lines: Iterable[tuple[Vec2, Vec2]],
|
||||
properties: BackendProperties,
|
||||
):
|
||||
"""Fast method to draw a bunch of solid lines with the same properties."""
|
||||
color = properties.color
|
||||
lineweight = self.get_lineweight(properties)
|
||||
_lines = []
|
||||
point_x = []
|
||||
point_y = []
|
||||
z = self._get_z()
|
||||
for s, e in lines:
|
||||
if s.isclose(e):
|
||||
point_x.append(s.x)
|
||||
point_y.append(s.y)
|
||||
else:
|
||||
_lines.append(((s.x, s.y), (e.x, e.y)))
|
||||
|
||||
self.ax.scatter(point_x, point_y, s=SCATTER_POINT_SIZE, c=color, zorder=z)
|
||||
self.ax.add_collection(
|
||||
LineCollection(
|
||||
_lines,
|
||||
linewidths=lineweight,
|
||||
color=color,
|
||||
zorder=z,
|
||||
capstyle="butt",
|
||||
)
|
||||
)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties):
|
||||
"""Draw a solid line path, line type rendering is done by the
|
||||
frontend since v0.18.1
|
||||
"""
|
||||
|
||||
mpl_path = to_matplotlib_path([path])
|
||||
try:
|
||||
patch = PathPatch(
|
||||
mpl_path,
|
||||
linewidth=self.get_lineweight(properties),
|
||||
fill=False,
|
||||
color=properties.color,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.info(f"ignored matplotlib error: {str(e)}")
|
||||
else:
|
||||
self.ax.add_patch(patch)
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
):
|
||||
linewidth = 0
|
||||
|
||||
try:
|
||||
patch = PathPatch(
|
||||
to_matplotlib_path(paths, detect_holes=True),
|
||||
color=properties.color,
|
||||
linewidth=linewidth,
|
||||
fill=True,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.info(f"ignored matplotlib error in draw_filled_paths(): {str(e)}")
|
||||
else:
|
||||
self.ax.add_patch(patch)
|
||||
|
||||
def draw_filled_polygon(self, points: BkPoints2d, properties: BackendProperties):
|
||||
self.ax.fill(
|
||||
*zip(*((p.x, p.y) for p in points.vertices())),
|
||||
color=properties.color,
|
||||
linewidth=0,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
|
||||
def draw_image(
|
||||
self, image_data: ImageData, properties: BackendProperties
|
||||
) -> None:
|
||||
height, width, depth = image_data.image.shape
|
||||
assert depth == 4
|
||||
|
||||
# using AxesImage directly avoids an issue with ax.imshow where the data limits
|
||||
# are updated to include the un-transformed image because the transform is applied
|
||||
# afterward. We can use a slight hack which is that the outlines of images are drawn
|
||||
# as well as the image itself, so we don't have to adjust the data limits at all here
|
||||
# as the outline will take care of that
|
||||
handle = AxesImage(self.ax, interpolation="antialiased")
|
||||
handle.set_data(np.flip(image_data.image, axis=0))
|
||||
handle.set_zorder(self._get_z())
|
||||
|
||||
(
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
m44,
|
||||
) = image_data.transform
|
||||
matplotlib_transform = Affine2D(
|
||||
matrix=np.array(
|
||||
[
|
||||
[m11, m21, m41],
|
||||
[m12, m22, m42],
|
||||
[0, 0, 1],
|
||||
]
|
||||
)
|
||||
)
|
||||
handle.set_transform(matplotlib_transform + self.ax.transData)
|
||||
self.ax.add_image(handle)
|
||||
|
||||
def clear(self):
|
||||
self.ax.clear()
|
||||
|
||||
def finalize(self):
|
||||
super().finalize()
|
||||
self.ax.autoscale(True)
|
||||
if self._adjust_figure:
|
||||
minx, maxx = self.ax.get_xlim()
|
||||
miny, maxy = self.ax.get_ylim()
|
||||
data_width, data_height = maxx - minx, maxy - miny
|
||||
if not math.isclose(data_width, 0):
|
||||
width, height = plt.figaspect(data_height / data_width)
|
||||
self.ax.get_figure().set_size_inches(width, height, forward=True)
|
||||
|
||||
|
||||
def _get_aspect_ratio(ax: plt.Axes) -> float:
|
||||
minx, maxx = ax.get_xlim()
|
||||
miny, maxy = ax.get_ylim()
|
||||
data_width, data_height = maxx - minx, maxy - miny
|
||||
if abs(data_height) > 1e-9:
|
||||
return data_width / data_height
|
||||
return 1.0
|
||||
|
||||
|
||||
def _get_width_height(ratio: float, width: float, height: float) -> tuple[float, float]:
|
||||
if width == 0.0 and height == 0.0:
|
||||
raise ValueError("invalid (width, height) values")
|
||||
if width == 0.0:
|
||||
width = height * ratio
|
||||
elif height == 0.0:
|
||||
height = width / ratio
|
||||
return width, height
|
||||
|
||||
|
||||
def qsave(
|
||||
layout: Layout,
|
||||
filename: Union[str, PathLike],
|
||||
*,
|
||||
bg: Optional[Color] = None,
|
||||
fg: Optional[Color] = None,
|
||||
dpi: int = 300,
|
||||
backend: str = "agg",
|
||||
config: Optional[Configuration] = None,
|
||||
filter_func: Optional[FilterFunc] = None,
|
||||
size_inches: Optional[tuple[float, float]] = None,
|
||||
) -> None:
|
||||
"""Quick and simplified render export by matplotlib.
|
||||
|
||||
Args:
|
||||
layout: modelspace or paperspace layout to export
|
||||
filename: export filename, file extension determines the format e.g.
|
||||
"image.png" to save in PNG format.
|
||||
bg: override default background color in hex format #RRGGBB or #RRGGBBAA,
|
||||
e.g. use bg="#FFFFFF00" to get a transparent background and a black
|
||||
foreground color (ACI=7), because a white background #FFFFFF gets a
|
||||
black foreground color or vice versa bg="#00000000" for a transparent
|
||||
(black) background and a white foreground color.
|
||||
fg: override default foreground color in hex format #RRGGBB or #RRGGBBAA,
|
||||
requires also `bg` argument. There is no explicit foreground color
|
||||
in DXF defined (also not a background color), but the ACI color 7
|
||||
has already a variable color value, black on a light background and
|
||||
white on a dark background, this argument overrides this (ACI=7)
|
||||
default color value.
|
||||
dpi: image resolution (dots per inches).
|
||||
size_inches: paper size in inch as `(width, height)` tuple, which also
|
||||
defines the size in pixels = (`width` * `dpi`) x (`height` * `dpi`).
|
||||
If `width` or `height` is 0.0 the value is calculated by the aspect
|
||||
ratio of the drawing.
|
||||
backend: the matplotlib rendering backend to use (agg, cairo, svg etc)
|
||||
(see documentation for `matplotlib.use() <https://matplotlib.org/3.1.1/api/matplotlib_configuration_api.html?highlight=matplotlib%20use#matplotlib.use>`_
|
||||
for a complete list of backends)
|
||||
config: drawing parameters
|
||||
filter_func: filter function which takes a DXFGraphic object as input
|
||||
and returns ``True`` if the entity should be drawn or ``False`` if
|
||||
the entity should be ignored
|
||||
|
||||
"""
|
||||
from .properties import RenderContext
|
||||
from .frontend import Frontend
|
||||
import matplotlib
|
||||
|
||||
# Set the backend to prevent warnings about GUIs being opened from a thread
|
||||
# other than the main thread.
|
||||
old_backend = matplotlib.get_backend()
|
||||
matplotlib.use(backend)
|
||||
if config is None:
|
||||
config = Configuration()
|
||||
|
||||
try:
|
||||
fig: plt.Figure = plt.figure(dpi=dpi)
|
||||
ax: plt.Axes = fig.add_axes((0, 0, 1, 1))
|
||||
ctx = RenderContext(layout.doc)
|
||||
layout_properties = LayoutProperties.from_layout(layout)
|
||||
if bg is not None:
|
||||
layout_properties.set_colors(bg, fg)
|
||||
out = MatplotlibBackend(ax)
|
||||
Frontend(ctx, out, config).draw_layout(
|
||||
layout,
|
||||
finalize=True,
|
||||
filter_func=filter_func,
|
||||
layout_properties=layout_properties,
|
||||
)
|
||||
# transparent=True sets the axes color to fully transparent
|
||||
# facecolor sets the figure color
|
||||
# (semi-)transparent axes colors do not produce transparent outputs
|
||||
# but (semi-)transparent figure colors do.
|
||||
if size_inches is not None:
|
||||
ratio = _get_aspect_ratio(ax)
|
||||
w, h = _get_width_height(ratio, size_inches[0], size_inches[1])
|
||||
fig.set_size_inches(w, h, True)
|
||||
fig.savefig(filename, dpi=dpi, facecolor=ax.get_facecolor(), transparent=True)
|
||||
plt.close(fig)
|
||||
finally:
|
||||
matplotlib.use(old_backend)
|
||||
@@ -0,0 +1,310 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Optional
|
||||
from typing_extensions import Protocol
|
||||
import copy
|
||||
import math
|
||||
|
||||
from ezdxf import colors
|
||||
from ezdxf.entities import MText
|
||||
from ezdxf.lldxf import const
|
||||
from ezdxf.math import Matrix44, Vec3, AnyVec
|
||||
from ezdxf.render.abstract_mtext_renderer import AbstractMTextRenderer
|
||||
from ezdxf.fonts import fonts
|
||||
from ezdxf.tools import text_layout as tl
|
||||
from ezdxf.tools.text import MTextContext
|
||||
from .properties import Properties, RenderContext, rgb_to_hex
|
||||
from .type_hints import Color
|
||||
|
||||
__all__ = ["complex_mtext_renderer"]
|
||||
|
||||
|
||||
def corner_vertices(
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Matrix44 = None,
|
||||
) -> Iterable[Vec3]:
|
||||
corners = [ # closed polygon: fist vertex == last vertex
|
||||
(left, top),
|
||||
(right, top),
|
||||
(right, bottom),
|
||||
(left, bottom),
|
||||
(left, top),
|
||||
]
|
||||
if m is None:
|
||||
return Vec3.generate(corners)
|
||||
else:
|
||||
return m.transform_vertices(corners)
|
||||
|
||||
|
||||
class DrawInterface(Protocol):
|
||||
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties) -> None:
|
||||
...
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: Iterable[AnyVec], properties: Properties
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def draw_text(
|
||||
self,
|
||||
text: str,
|
||||
transform: Matrix44,
|
||||
properties: Properties,
|
||||
cap_height: float,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
|
||||
class FrameRenderer(tl.ContentRenderer):
|
||||
def __init__(self, properties: Properties, backend: DrawInterface):
|
||||
self.properties = properties
|
||||
self.backend = backend
|
||||
|
||||
def render(
|
||||
self,
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Matrix44 = None,
|
||||
) -> None:
|
||||
self._render_outline(list(corner_vertices(left, bottom, right, top, m)))
|
||||
|
||||
def _render_outline(self, vertices: list[Vec3]) -> None:
|
||||
backend = self.backend
|
||||
properties = self.properties
|
||||
prev = vertices.pop(0)
|
||||
for vertex in vertices:
|
||||
backend.draw_line(prev, vertex, properties)
|
||||
prev = vertex
|
||||
|
||||
def line(
|
||||
self, x1: float, y1: float, x2: float, y2: float, m: Matrix44 = None
|
||||
) -> None:
|
||||
points = [(x1, y1), (x2, y2)]
|
||||
if m is not None:
|
||||
p1, p2 = m.transform_vertices(points)
|
||||
else:
|
||||
p1, p2 = Vec3.generate(points)
|
||||
self.backend.draw_line(p1, p2, self.properties)
|
||||
|
||||
|
||||
class ColumnBackgroundRenderer(FrameRenderer):
|
||||
def __init__(
|
||||
self,
|
||||
properties: Properties,
|
||||
backend: DrawInterface,
|
||||
bg_properties: Optional[Properties] = None,
|
||||
offset: float = 0,
|
||||
text_frame: bool = False,
|
||||
):
|
||||
super().__init__(properties, backend)
|
||||
self.bg_properties = bg_properties
|
||||
self.offset = offset # background border offset
|
||||
self.has_text_frame = text_frame
|
||||
|
||||
def render(
|
||||
self,
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Matrix44 = None,
|
||||
) -> None:
|
||||
# Important: this is not a clipping box, it is possible to
|
||||
# render anything outside of the given borders!
|
||||
offset = self.offset
|
||||
vertices = list(
|
||||
corner_vertices(
|
||||
left - offset, bottom - offset, right + offset, top + offset, m
|
||||
)
|
||||
)
|
||||
if self.bg_properties is not None:
|
||||
self.backend.draw_filled_polygon(vertices, self.bg_properties)
|
||||
if self.has_text_frame:
|
||||
self._render_outline(vertices)
|
||||
|
||||
|
||||
class TextRenderer(FrameRenderer):
|
||||
"""Text content renderer."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
cap_height: float,
|
||||
width_factor: float,
|
||||
oblique: float, # angle in degrees
|
||||
properties: Properties,
|
||||
backend: DrawInterface,
|
||||
):
|
||||
super().__init__(properties, backend)
|
||||
self.text = text
|
||||
self.cap_height = cap_height
|
||||
self.width_factor = width_factor
|
||||
self.oblique = oblique # angle in degrees
|
||||
|
||||
def render(
|
||||
self,
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Matrix44 = None,
|
||||
):
|
||||
"""Create/render the text content"""
|
||||
sx = 1.0
|
||||
tx = 0.0
|
||||
if not math.isclose(self.width_factor, 1.0, rel_tol=1e-6):
|
||||
sx = self.width_factor
|
||||
if abs(self.oblique) > 1e-3: # degrees
|
||||
tx = math.tan(math.radians(self.oblique))
|
||||
# fmt: off
|
||||
t = Matrix44((
|
||||
sx, 0.0, 0.0, 0.0,
|
||||
tx, 1.0, 0.0, 0.0,
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
left, bottom, 0.0, 1.0
|
||||
))
|
||||
# fmt: on
|
||||
if m is not None:
|
||||
t *= m
|
||||
self.backend.draw_text(self.text, t, self.properties, self.cap_height)
|
||||
|
||||
|
||||
def complex_mtext_renderer(
|
||||
ctx: RenderContext,
|
||||
backend: DrawInterface,
|
||||
mtext: MText,
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
cmr = ComplexMTextRenderer(ctx, backend, properties)
|
||||
align = tl.LayoutAlignment(mtext.dxf.attachment_point)
|
||||
layout_engine = cmr.layout_engine(mtext)
|
||||
layout_engine.place(align=align)
|
||||
layout_engine.render(mtext.ucs().matrix)
|
||||
|
||||
|
||||
class ComplexMTextRenderer(AbstractMTextRenderer):
|
||||
def __init__(
|
||||
self,
|
||||
ctx: RenderContext,
|
||||
backend: DrawInterface,
|
||||
properties: Properties,
|
||||
):
|
||||
super().__init__()
|
||||
self._render_ctx = ctx
|
||||
self._backend = backend
|
||||
self._properties = properties
|
||||
|
||||
# Implementation of required AbstractMTextRenderer methods:
|
||||
|
||||
def word(self, text: str, ctx: MTextContext) -> tl.ContentCell:
|
||||
return tl.Text(
|
||||
width=self.get_font(ctx).text_width(text),
|
||||
height=ctx.cap_height,
|
||||
valign=tl.CellAlignment(ctx.align),
|
||||
stroke=self.get_stroke(ctx),
|
||||
renderer=TextRenderer(
|
||||
text,
|
||||
ctx.cap_height,
|
||||
ctx.width_factor,
|
||||
ctx.oblique,
|
||||
self.new_text_properties(self._properties, ctx),
|
||||
self._backend,
|
||||
),
|
||||
)
|
||||
|
||||
def fraction(self, data: tuple[str, str, str], ctx: MTextContext) -> tl.ContentCell:
|
||||
upr, lwr, type_ = data
|
||||
if type_:
|
||||
return tl.Fraction(
|
||||
top=self.word(upr, ctx),
|
||||
bottom=self.word(lwr, ctx),
|
||||
stacking=self.get_stacking(type_),
|
||||
# renders just the divider line:
|
||||
renderer=FrameRenderer(self._properties, self._backend),
|
||||
)
|
||||
else:
|
||||
return self.word(upr, ctx)
|
||||
|
||||
def get_font_face(self, mtext: MText) -> fonts.FontFace:
|
||||
return self._properties.font # type: ignore
|
||||
|
||||
def make_bg_renderer(self, mtext: MText) -> tl.ContentRenderer:
|
||||
dxf = mtext.dxf
|
||||
bg_fill = dxf.get("bg_fill", 0)
|
||||
|
||||
bg_aci = None
|
||||
bg_true_color = None
|
||||
bg_properties: Optional[Properties] = None
|
||||
has_text_frame = False
|
||||
offset = 0
|
||||
if bg_fill:
|
||||
# The fill scale is a multiple of the initial char height and
|
||||
# a scale of 1, fits exact the outer border
|
||||
# of the column -> offset = 0
|
||||
offset = dxf.char_height * (dxf.get("box_fill_scale", 1.5) - 1)
|
||||
if bg_fill & const.MTEXT_BG_COLOR:
|
||||
if dxf.hasattr("bg_fill_color"):
|
||||
bg_aci = dxf.bg_fill_color
|
||||
|
||||
if dxf.hasattr("bg_fill_true_color"):
|
||||
bg_aci = None
|
||||
bg_true_color = dxf.bg_fill_true_color
|
||||
|
||||
if (bg_fill & 3) == 3: # canvas color = bit 0 and 1 set
|
||||
# can not detect canvas color from DXF document!
|
||||
# do not draw any background:
|
||||
bg_aci = None
|
||||
bg_true_color = None
|
||||
|
||||
if bg_fill & const.MTEXT_TEXT_FRAME:
|
||||
has_text_frame = True
|
||||
bg_properties = self.new_bg_properties(bg_aci, bg_true_color)
|
||||
|
||||
return ColumnBackgroundRenderer(
|
||||
self._properties,
|
||||
self._backend,
|
||||
bg_properties,
|
||||
offset=offset,
|
||||
text_frame=has_text_frame,
|
||||
)
|
||||
|
||||
# Implementation details of ComplexMTextRenderer:
|
||||
|
||||
@property
|
||||
def backend(self) -> DrawInterface:
|
||||
return self._backend
|
||||
|
||||
def resolve_aci_color(self, aci: int) -> Color:
|
||||
return self._render_ctx.resolve_aci_color(aci, self._properties.layer)
|
||||
|
||||
def new_text_properties(
|
||||
self, properties: Properties, ctx: MTextContext
|
||||
) -> Properties:
|
||||
new_properties = copy.copy(properties)
|
||||
if ctx.rgb is None:
|
||||
new_properties.color = self.resolve_aci_color(ctx.aci)
|
||||
else:
|
||||
new_properties.color = rgb_to_hex(ctx.rgb)
|
||||
new_properties.font = ctx.font_face
|
||||
return new_properties
|
||||
|
||||
def new_bg_properties(
|
||||
self, aci: Optional[int], true_color: Optional[int]
|
||||
) -> Properties:
|
||||
new_properties = copy.copy(self._properties)
|
||||
new_properties.color = ( # canvas background color
|
||||
self._render_ctx.current_layout_properties.background_color
|
||||
)
|
||||
if true_color is None:
|
||||
if aci is not None:
|
||||
new_properties.color = self.resolve_aci_color(aci)
|
||||
# else canvas background color
|
||||
else:
|
||||
new_properties.color = rgb_to_hex(colors.int2rgb(true_color))
|
||||
return new_properties
|
||||
@@ -0,0 +1,886 @@
|
||||
# Copyright (c) 2023-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Sequence,
|
||||
Optional,
|
||||
Iterable,
|
||||
Tuple,
|
||||
Iterator,
|
||||
Callable,
|
||||
)
|
||||
from typing_extensions import TypeAlias
|
||||
import abc
|
||||
|
||||
import numpy as np
|
||||
import PIL.Image
|
||||
import PIL.ImageDraw
|
||||
import PIL.ImageOps
|
||||
|
||||
|
||||
from ezdxf.colors import RGB
|
||||
import ezdxf.bbox
|
||||
|
||||
from ezdxf.fonts import fonts
|
||||
from ezdxf.math import Vec2, Matrix44, BoundingBox2d, AnyVec
|
||||
from ezdxf.path import make_path, Path
|
||||
from ezdxf.render import linetypes
|
||||
from ezdxf.entities import DXFGraphic, Viewport
|
||||
from ezdxf.tools.text import replace_non_printable_characters
|
||||
from ezdxf.tools.clipping_portal import (
|
||||
ClippingPortal,
|
||||
ClippingShape,
|
||||
find_best_clipping_shape,
|
||||
)
|
||||
from ezdxf.layouts import Layout
|
||||
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
|
||||
from .config import LinePolicy, TextPolicy, ColorPolicy, Configuration
|
||||
from .properties import BackendProperties, Filling
|
||||
from .properties import Properties, RenderContext
|
||||
from .type_hints import Color
|
||||
from .unified_text_renderer import UnifiedTextRenderer
|
||||
|
||||
PatternKey: TypeAlias = Tuple[str, float]
|
||||
DrawEntitiesCallback: TypeAlias = Callable[[RenderContext, Iterable[DXFGraphic]], None]
|
||||
|
||||
__all__ = ["AbstractPipeline", "RenderPipeline2d"]
|
||||
|
||||
|
||||
class AbstractPipeline(abc.ABC):
|
||||
"""This drawing pipeline separates the frontend from the backend and implements
|
||||
these features:
|
||||
|
||||
- automatically linetype rendering
|
||||
- font rendering
|
||||
- VIEWPORT rendering
|
||||
- foreground color mapping according Frontend.config.color_policy
|
||||
|
||||
The pipeline is organized as concatenated render stages.
|
||||
|
||||
"""
|
||||
|
||||
text_engine = UnifiedTextRenderer()
|
||||
default_font_face = fonts.FontFace()
|
||||
draw_entities: DrawEntitiesCallback
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_draw_entities_callback(self, callback: DrawEntitiesCallback) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_config(self, config: Configuration) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_current_entity_handle(self, handle: str) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def push_clipping_shape(
|
||||
self, shape: ClippingShape, transform: Matrix44 | None
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def pop_clipping_shape(self) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_viewport(
|
||||
self,
|
||||
vp: Viewport,
|
||||
layout_ctx: RenderContext,
|
||||
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
|
||||
) -> None:
|
||||
"""Draw the content of the given viewport current viewport."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_point(self, pos: AnyVec, properties: Properties) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties): ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[AnyVec, AnyVec]], properties: Properties
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_path(self, path: Path, properties: Properties): ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: Iterable[Path],
|
||||
properties: Properties,
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_filled_polygon(
|
||||
self, points: Iterable[AnyVec], properties: Properties
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_text(
|
||||
self,
|
||||
text: str,
|
||||
transform: Matrix44,
|
||||
properties: Properties,
|
||||
cap_height: float,
|
||||
dxftype: str = "TEXT",
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def finalize(self) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_background(self, color: Color) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
|
||||
# gets the full DXF properties information
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def exit_entity(self, entity: DXFGraphic) -> None: ...
|
||||
|
||||
|
||||
class RenderStage2d(abc.ABC):
|
||||
next_stage: RenderStage2d
|
||||
|
||||
def set_config(self, config: Configuration) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_point(self, pos: Vec2, properties: Properties) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: Properties): ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_solid_lines(
|
||||
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_path(self, path: BkPath2d, properties: Properties): ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: list[BkPath2d],
|
||||
properties: Properties,
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: Properties
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None: ...
|
||||
|
||||
|
||||
class RenderPipeline2d(AbstractPipeline):
|
||||
"""Render pipeline for 2D backends."""
|
||||
|
||||
def __init__(self, backend: BackendInterface):
|
||||
self.backend = backend
|
||||
self.config = Configuration()
|
||||
try: # request default font face
|
||||
self.default_font_face = fonts.font_manager.get_font_face("")
|
||||
except fonts.FontNotFoundError: # no default font found
|
||||
# last resort MonospaceFont which renders only "tofu"
|
||||
pass
|
||||
self.clipping_portal = ClippingPortal()
|
||||
self.current_vp_scale = 1.0
|
||||
self._current_entity_handle: str = ""
|
||||
self._color_mapping: dict[str, str] = dict()
|
||||
self._pipeline = self.build_render_pipeline()
|
||||
|
||||
def build_render_pipeline(self) -> RenderStage2d:
|
||||
backend_stage = BackendStage2d(
|
||||
self.backend, converter=self.get_backend_properties
|
||||
)
|
||||
linetype_stage = LinetypeStage2d(
|
||||
self.config,
|
||||
get_ltype_scale=self.get_vp_ltype_scale,
|
||||
next_stage=backend_stage,
|
||||
)
|
||||
clipping_stage = ClippingStage2d(
|
||||
self.config, self.clipping_portal, next_stage=linetype_stage
|
||||
)
|
||||
return clipping_stage
|
||||
|
||||
def get_vp_ltype_scale(self) -> float:
|
||||
"""The linetype pattern should look the same in all viewports
|
||||
regardless of the viewport scale.
|
||||
"""
|
||||
return 1.0 / max(self.current_vp_scale, 0.0001) # max out at 1:10000
|
||||
|
||||
def get_backend_properties(self, properties: Properties) -> BackendProperties:
|
||||
try:
|
||||
color = self._color_mapping[properties.color]
|
||||
except KeyError:
|
||||
color = apply_color_policy(
|
||||
properties.color, self.config.color_policy, self.config.custom_fg_color
|
||||
)
|
||||
self._color_mapping[properties.color] = color
|
||||
return BackendProperties(
|
||||
color,
|
||||
properties.lineweight,
|
||||
properties.layer,
|
||||
properties.pen,
|
||||
self._current_entity_handle,
|
||||
)
|
||||
|
||||
def set_draw_entities_callback(self, callback: DrawEntitiesCallback) -> None:
|
||||
self.draw_entities = callback
|
||||
|
||||
def set_config(self, config: Configuration) -> None:
|
||||
self.backend.configure(config)
|
||||
self.config = config
|
||||
stage = self._pipeline
|
||||
while True:
|
||||
stage.set_config(config)
|
||||
if not hasattr(stage, "next_stage"): # BackendStage2d
|
||||
return
|
||||
stage = stage.next_stage
|
||||
|
||||
def set_current_entity_handle(self, handle: str) -> None:
|
||||
assert handle is not None
|
||||
self._current_entity_handle = handle
|
||||
|
||||
def push_clipping_shape(
|
||||
self, shape: ClippingShape, transform: Matrix44 | None
|
||||
) -> None:
|
||||
self.clipping_portal.push(shape, transform)
|
||||
|
||||
def pop_clipping_shape(self) -> None:
|
||||
self.clipping_portal.pop()
|
||||
|
||||
def draw_viewport(
|
||||
self,
|
||||
vp: Viewport,
|
||||
layout_ctx: RenderContext,
|
||||
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
|
||||
) -> None:
|
||||
"""Draw the content of the given viewport current viewport."""
|
||||
if vp.doc is None:
|
||||
return
|
||||
try:
|
||||
msp_limits = vp.get_modelspace_limits()
|
||||
except ValueError: # modelspace limits not detectable
|
||||
return
|
||||
if self.enter_viewport(vp):
|
||||
self.draw_entities(
|
||||
layout_ctx.from_viewport(vp),
|
||||
filter_vp_entities(vp.doc.modelspace(), msp_limits, bbox_cache),
|
||||
)
|
||||
self.exit_viewport()
|
||||
|
||||
def enter_viewport(self, vp: Viewport) -> bool:
|
||||
"""Set current viewport, returns ``True`` for valid viewports."""
|
||||
self.current_vp_scale = vp.get_scale()
|
||||
m = vp.get_transformation_matrix()
|
||||
clipping_path = make_path(vp)
|
||||
if len(clipping_path):
|
||||
vertices = clipping_path.control_vertices()
|
||||
if clipping_path.has_curves:
|
||||
layout = vp.get_layout()
|
||||
if isinstance(layout, Layout):
|
||||
# plot paper units:
|
||||
# 0: inches, max sagitta = 1/254 = 0.1 mm
|
||||
# 1: millimeters, max sagitta = 0.1 mm
|
||||
# 2: pixels, max sagitta = 0.1 pixel
|
||||
units = layout.dxf.get("plot_paper_units", 1)
|
||||
max_sagitta = 1.0 / 254.0 if units == 0 else 0.1
|
||||
vertices = list(clipping_path.flattening(max_sagitta))
|
||||
clipping_shape = find_best_clipping_shape(vertices)
|
||||
self.clipping_portal.push(clipping_shape, m)
|
||||
return True
|
||||
return False
|
||||
|
||||
def exit_viewport(self):
|
||||
self.clipping_portal.pop()
|
||||
# Reset viewport scaling: viewports cannot be nested!
|
||||
self.current_vp_scale = 1.0
|
||||
|
||||
def draw_text(
|
||||
self,
|
||||
text: str,
|
||||
transform: Matrix44,
|
||||
properties: Properties,
|
||||
cap_height: float,
|
||||
dxftype: str = "TEXT",
|
||||
) -> None:
|
||||
"""Render text as filled paths."""
|
||||
text_policy = self.config.text_policy
|
||||
pipeline = self._pipeline
|
||||
|
||||
if not text.strip() or text_policy == TextPolicy.IGNORE:
|
||||
return # no point rendering empty strings
|
||||
text = prepare_string_for_rendering(text, dxftype)
|
||||
font_face = properties.font
|
||||
if font_face is None:
|
||||
font_face = self.default_font_face
|
||||
|
||||
try:
|
||||
glyph_paths = self.text_engine.get_text_glyph_paths(
|
||||
text, font_face, cap_height
|
||||
)
|
||||
except (RuntimeError, ValueError):
|
||||
return
|
||||
for p in glyph_paths:
|
||||
p.transform_inplace(transform)
|
||||
transformed_paths: list[BkPath2d] = glyph_paths
|
||||
|
||||
points: list[Vec2]
|
||||
if text_policy == TextPolicy.REPLACE_RECT:
|
||||
points = []
|
||||
for p in transformed_paths:
|
||||
points.extend(p.extents())
|
||||
if len(points) < 2:
|
||||
return
|
||||
rect = BkPath2d.from_vertices(BoundingBox2d(points).rect_vertices())
|
||||
pipeline.draw_path(rect, properties)
|
||||
return
|
||||
if text_policy == TextPolicy.REPLACE_FILL:
|
||||
points = []
|
||||
for p in transformed_paths:
|
||||
points.extend(p.extents())
|
||||
if len(points) < 2:
|
||||
return
|
||||
polygon = BkPoints2d(BoundingBox2d(points).rect_vertices())
|
||||
if properties.filling is None:
|
||||
properties.filling = Filling()
|
||||
pipeline.draw_filled_polygon(polygon, properties)
|
||||
return
|
||||
|
||||
if (
|
||||
self.text_engine.is_stroke_font(font_face)
|
||||
or text_policy == TextPolicy.OUTLINE
|
||||
):
|
||||
for text_path in transformed_paths:
|
||||
pipeline.draw_path(text_path, properties)
|
||||
return
|
||||
|
||||
if properties.filling is None:
|
||||
properties.filling = Filling()
|
||||
pipeline.draw_filled_paths(transformed_paths, properties)
|
||||
|
||||
def finalize(self) -> None:
|
||||
self.backend.finalize()
|
||||
|
||||
def set_background(self, color: Color) -> None:
|
||||
self.backend.set_background(color)
|
||||
|
||||
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
|
||||
self.backend.enter_entity(entity, properties)
|
||||
|
||||
def exit_entity(self, entity: DXFGraphic) -> None:
|
||||
self.backend.exit_entity(entity)
|
||||
|
||||
# Enter render pipeline:
|
||||
def draw_point(self, pos: AnyVec, properties: Properties) -> None:
|
||||
self._pipeline.draw_point(Vec2(pos), properties)
|
||||
|
||||
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties):
|
||||
self._pipeline.draw_line(Vec2(start), Vec2(end), properties)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[AnyVec, AnyVec]], properties: Properties
|
||||
) -> None:
|
||||
self._pipeline.draw_solid_lines(
|
||||
[(Vec2(s), Vec2(e)) for s, e in lines], properties
|
||||
)
|
||||
|
||||
def draw_path(self, path: Path, properties: Properties):
|
||||
self._pipeline.draw_path(BkPath2d(path), properties)
|
||||
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: Iterable[Path],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
self._pipeline.draw_filled_paths(list(map(BkPath2d, paths)), properties)
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: Iterable[AnyVec], properties: Properties
|
||||
) -> None:
|
||||
self._pipeline.draw_filled_polygon(BkPoints2d(points), properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
|
||||
self._pipeline.draw_image(image_data, properties)
|
||||
|
||||
|
||||
class ClippingStage2d(RenderStage2d):
|
||||
def __init__(
|
||||
self,
|
||||
config: Configuration,
|
||||
clipping_portal: ClippingPortal,
|
||||
next_stage: RenderStage2d,
|
||||
):
|
||||
self.clipping_portal = clipping_portal
|
||||
self.config = config
|
||||
self.next_stage = next_stage
|
||||
|
||||
def set_config(self, config: Configuration) -> None:
|
||||
self.config = config
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: Properties) -> None:
|
||||
if self.clipping_portal.is_active:
|
||||
pos = self.clipping_portal.clip_point(pos)
|
||||
if pos is None:
|
||||
return
|
||||
self.next_stage.draw_point(pos, properties)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
|
||||
next_stage = self.next_stage
|
||||
clipping_portal = self.clipping_portal
|
||||
|
||||
if clipping_portal.is_active:
|
||||
for segment in clipping_portal.clip_line(start, end):
|
||||
next_stage.draw_line(segment[0], segment[1], properties)
|
||||
return
|
||||
next_stage.draw_line(start, end, properties)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
|
||||
) -> None:
|
||||
clipping_portal = self.clipping_portal
|
||||
|
||||
if clipping_portal.is_active:
|
||||
cropped_lines: list[tuple[Vec2, Vec2]] = []
|
||||
for start, end in lines:
|
||||
cropped_lines.extend(clipping_portal.clip_line(start, end))
|
||||
lines = cropped_lines
|
||||
self.next_stage.draw_solid_lines(lines, properties)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: Properties):
|
||||
clipping_portal = self.clipping_portal
|
||||
next_stage = self.next_stage
|
||||
max_sagitta = self.config.max_flattening_distance
|
||||
|
||||
if clipping_portal.is_active:
|
||||
for clipped_path in clipping_portal.clip_paths([path], max_sagitta):
|
||||
next_stage.draw_path(clipped_path, properties)
|
||||
return
|
||||
next_stage.draw_path(path, properties)
|
||||
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: list[BkPath2d],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
clipping_portal = self.clipping_portal
|
||||
max_sagitta = self.config.max_flattening_distance
|
||||
|
||||
if clipping_portal.is_active:
|
||||
paths = clipping_portal.clip_filled_paths(paths, max_sagitta)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
self.next_stage.draw_filled_paths(paths, properties)
|
||||
|
||||
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
|
||||
clipping_portal = self.clipping_portal
|
||||
next_stage = self.next_stage
|
||||
|
||||
if clipping_portal.is_active:
|
||||
for points in clipping_portal.clip_polygon(points):
|
||||
if len(points) > 0:
|
||||
next_stage.draw_filled_polygon(points, properties)
|
||||
return
|
||||
|
||||
if len(points) > 0:
|
||||
next_stage.draw_filled_polygon(points, properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
|
||||
# the outer bounds contain the visible parts of the image for the
|
||||
# clip mode "remove inside"
|
||||
outer_bounds: list[BkPoints2d] = []
|
||||
clipping_portal = self.clipping_portal
|
||||
|
||||
if not clipping_portal.is_active:
|
||||
self._draw_image(image_data, outer_bounds, properties)
|
||||
return
|
||||
|
||||
# the pixel boundary path can be split into multiple paths
|
||||
transform = image_data.flip_matrix() * image_data.transform
|
||||
pixel_boundary_path = image_data.pixel_boundary_path
|
||||
clipping_paths = _clip_image_polygon(
|
||||
clipping_portal, pixel_boundary_path, transform
|
||||
)
|
||||
if not image_data.remove_outside:
|
||||
# remove inside:
|
||||
# detect the visible parts of the image which are not removed by
|
||||
# clipping through viewports or block references
|
||||
width, height = image_data.image_size()
|
||||
outer_boundary = BkPoints2d(
|
||||
Vec2.generate([(0, 0), (width, 0), (width, height), (0, height)])
|
||||
)
|
||||
outer_bounds = _clip_image_polygon(
|
||||
clipping_portal, outer_boundary, transform
|
||||
)
|
||||
image_data.transform = clipping_portal.transform_matrix(image_data.transform)
|
||||
if len(clipping_paths) == 1:
|
||||
new_clipping_path = clipping_paths[0]
|
||||
if new_clipping_path is not image_data.pixel_boundary_path:
|
||||
image_data.pixel_boundary_path = new_clipping_path
|
||||
# forced clipping triggered by viewport- or block reference clipping:
|
||||
image_data.use_clipping_boundary = True
|
||||
self._draw_image(image_data, outer_bounds, properties)
|
||||
else:
|
||||
for clipping_path in clipping_paths:
|
||||
# when clipping path is split into multiple parts:
|
||||
# copy image for each part, not efficient but works
|
||||
# this should be a rare usecase so optimization is not required
|
||||
self._draw_image(
|
||||
ImageData(
|
||||
image=image_data.image.copy(),
|
||||
transform=image_data.transform,
|
||||
pixel_boundary_path=clipping_path,
|
||||
use_clipping_boundary=True,
|
||||
),
|
||||
outer_bounds,
|
||||
properties,
|
||||
)
|
||||
|
||||
def _draw_image(
|
||||
self,
|
||||
image_data: ImageData,
|
||||
outer_bounds: list[BkPoints2d],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
if image_data.use_clipping_boundary:
|
||||
_mask_image(image_data, outer_bounds)
|
||||
self.next_stage.draw_image(image_data, properties)
|
||||
|
||||
|
||||
class LinetypeStage2d(RenderStage2d):
|
||||
def __init__(
|
||||
self,
|
||||
config: Configuration,
|
||||
get_ltype_scale: Callable[[], float],
|
||||
next_stage: RenderStage2d,
|
||||
):
|
||||
self.config = config
|
||||
self.solid_lines_only = False
|
||||
self.next_stage = next_stage
|
||||
self.get_ltype_scale = get_ltype_scale
|
||||
self.pattern_cache: dict[PatternKey, Sequence[float]] = dict()
|
||||
self.set_config(config)
|
||||
|
||||
def set_config(self, config: Configuration) -> None:
|
||||
self.config = config
|
||||
self.solid_lines_only = config.line_policy == LinePolicy.SOLID
|
||||
|
||||
def pattern(self, properties: Properties) -> Sequence[float]:
|
||||
"""Returns simplified linetype tuple: on-off sequence"""
|
||||
if self.solid_lines_only:
|
||||
scale = 0.0
|
||||
else:
|
||||
scale = properties.linetype_scale * self.get_ltype_scale()
|
||||
|
||||
key: PatternKey = (properties.linetype_name, scale)
|
||||
pattern_ = self.pattern_cache.get(key)
|
||||
if pattern_ is None:
|
||||
pattern_ = self._create_pattern(properties, scale)
|
||||
self.pattern_cache[key] = pattern_
|
||||
return pattern_
|
||||
|
||||
def _create_pattern(self, properties: Properties, scale: float) -> Sequence[float]:
|
||||
if len(properties.linetype_pattern) < 2:
|
||||
# Do not return None -> None indicates: "not cached"
|
||||
return tuple()
|
||||
|
||||
min_dash_length = self.config.min_dash_length * self.get_ltype_scale()
|
||||
pattern = [max(e * scale, min_dash_length) for e in properties.linetype_pattern]
|
||||
if len(pattern) % 2:
|
||||
pattern.pop()
|
||||
return pattern
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: Properties) -> None:
|
||||
self.next_stage.draw_point(pos, properties)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
|
||||
s = Vec2(start)
|
||||
e = Vec2(end)
|
||||
next_stage = self.next_stage
|
||||
|
||||
if self.solid_lines_only or len(properties.linetype_pattern) < 2: # CONTINUOUS
|
||||
next_stage.draw_line(s, e, properties)
|
||||
return
|
||||
|
||||
renderer = linetypes.LineTypeRenderer(self.pattern(properties))
|
||||
next_stage.draw_solid_lines(
|
||||
[(s, e) for s, e in renderer.line_segment(s, e)],
|
||||
properties,
|
||||
)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
|
||||
) -> None:
|
||||
self.next_stage.draw_solid_lines(lines, properties)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: Properties):
|
||||
next_stage = self.next_stage
|
||||
|
||||
if self.solid_lines_only or len(properties.linetype_pattern) < 2: # CONTINUOUS
|
||||
next_stage.draw_path(path, properties)
|
||||
return
|
||||
|
||||
renderer = linetypes.LineTypeRenderer(self.pattern(properties))
|
||||
vertices = path.flattening(self.config.max_flattening_distance, segments=16)
|
||||
next_stage.draw_solid_lines(
|
||||
[(Vec2(s), Vec2(e)) for s, e in renderer.line_segments(vertices)],
|
||||
properties,
|
||||
)
|
||||
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: list[BkPath2d],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
self.next_stage.draw_filled_paths(paths, properties)
|
||||
|
||||
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
|
||||
self.next_stage.draw_filled_polygon(points, properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
|
||||
self.next_stage.draw_image(image_data, properties)
|
||||
|
||||
|
||||
class BackendStage2d(RenderStage2d):
|
||||
"""Send data to the output backend."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: BackendInterface,
|
||||
converter: Callable[[Properties], BackendProperties],
|
||||
):
|
||||
self.backend = backend
|
||||
self.converter = converter
|
||||
assert not hasattr(self, "next_stage"), "has to be the last render stage"
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: Properties) -> None:
|
||||
self.backend.draw_point(pos, self.converter(properties))
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
|
||||
self.backend.draw_line(start, end, self.converter(properties))
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
|
||||
) -> None:
|
||||
self.backend.draw_solid_lines(lines, self.converter(properties))
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: Properties):
|
||||
self.backend.draw_path(path, self.converter(properties))
|
||||
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: list[BkPath2d],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
self.backend.draw_filled_paths(paths, self.converter(properties))
|
||||
|
||||
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
|
||||
self.backend.draw_filled_polygon(points, self.converter(properties))
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
|
||||
self.backend.draw_image(image_data, self.converter(properties))
|
||||
|
||||
|
||||
def _mask_image(image_data: ImageData, outer_bounds: list[BkPoints2d]) -> None:
|
||||
"""Mask away the clipped parts of the image. The argument `outer_bounds` is only
|
||||
used for clip mode "remove_inside". The outer bounds can be composed of multiple
|
||||
parts. If `outer_bounds` is empty the image has no removed parts and is fully
|
||||
visible before applying the image clipping path.
|
||||
|
||||
Args:
|
||||
image_data:
|
||||
image_data.pixel_boundary: path contains the image clipping path
|
||||
image_data.remove_outside: defines the clipping mode (inside/outside)
|
||||
outer_bounds: countain the parts of the image which are __not__ removed by
|
||||
clipping through viewports or clipped block references
|
||||
e.g. an image without any removed parts has the outer bounds
|
||||
[(0, 0) (width, 0), (width, height), (0, height)]
|
||||
|
||||
"""
|
||||
clip_polygon = [(p.x, p.y) for p in image_data.pixel_boundary_path.vertices()]
|
||||
# create an empty image
|
||||
clipping_image = PIL.Image.new("L", image_data.image_size(), 0)
|
||||
# paint in the clipping path
|
||||
PIL.ImageDraw.ImageDraw(clipping_image).polygon(
|
||||
clip_polygon, outline=None, width=0, fill=1
|
||||
)
|
||||
clipping_mask = np.asarray(clipping_image)
|
||||
|
||||
if not image_data.remove_outside: # clip mode "remove_inside"
|
||||
if outer_bounds:
|
||||
# create a new empty image
|
||||
visible_image = PIL.Image.new("L", image_data.image_size(), 0)
|
||||
# paint in parts of the image which are still visible
|
||||
for boundary in outer_bounds:
|
||||
clip_polygon = [(p.x, p.y) for p in boundary.vertices()]
|
||||
PIL.ImageDraw.ImageDraw(visible_image).polygon(
|
||||
clip_polygon, outline=None, width=0, fill=1
|
||||
)
|
||||
# remove the clipping path
|
||||
clipping_mask = np.asarray(visible_image) - clipping_mask
|
||||
else:
|
||||
# create mask for fully visible image
|
||||
fully_visible_image_mask = np.full(
|
||||
clipping_mask.shape, fill_value=1, dtype=clipping_mask.dtype
|
||||
)
|
||||
# remove the clipping path
|
||||
clipping_mask = fully_visible_image_mask - clipping_mask
|
||||
image_data.image[:, :, 3] *= clipping_mask
|
||||
|
||||
|
||||
def _clip_image_polygon(
|
||||
clipping_portal: ClippingPortal, polygon_px: BkPoints2d, m: Matrix44
|
||||
) -> list[BkPoints2d]:
|
||||
original = [polygon_px]
|
||||
|
||||
# inverse matrix includes the transformation applied by the clipping portal
|
||||
inverse = clipping_portal.transform_matrix(m)
|
||||
try:
|
||||
inverse.inverse()
|
||||
except ZeroDivisionError:
|
||||
# inverse transformation from WCS to pixel coordinates is not possible
|
||||
return original
|
||||
|
||||
# transform image coordinates to WCS coordinates
|
||||
polygon = polygon_px.clone()
|
||||
polygon.transform_inplace(m)
|
||||
|
||||
clipped_polygons = clipping_portal.clip_polygon(polygon)
|
||||
if (len(clipped_polygons) == 1) and (clipped_polygons[0] is polygon):
|
||||
# this shows the caller that the image boundary path wasn't clipped
|
||||
return original
|
||||
# transform WCS coordinates to image coordinates
|
||||
for polygon in clipped_polygons:
|
||||
polygon.transform_inplace(inverse)
|
||||
return clipped_polygons # in image coordinates!
|
||||
|
||||
|
||||
def invert_color(color: Color) -> Color:
|
||||
r, g, b = RGB.from_hex(color)
|
||||
return RGB(255 - r, 255 - g, 255 - b).to_hex()
|
||||
|
||||
|
||||
def swap_bw(color: str) -> Color:
|
||||
color = color.lower()
|
||||
if color == "#000000":
|
||||
return "#ffffff"
|
||||
if color == "#ffffff":
|
||||
return "#000000"
|
||||
return color
|
||||
|
||||
|
||||
def color_to_monochrome(color: Color, scale: float = 1.0, offset: float = 0.0) -> Color:
|
||||
lum = RGB.from_hex(color).luminance * scale + offset
|
||||
if lum < 0.0:
|
||||
lum = 0.0
|
||||
elif lum > 1.0:
|
||||
lum = 1.0
|
||||
gray = round(lum * 255)
|
||||
return RGB(gray, gray, gray).to_hex()
|
||||
|
||||
|
||||
def apply_color_policy(color: Color, policy: ColorPolicy, custom_color: Color) -> Color:
|
||||
alpha = color[7:9]
|
||||
color = color[:7]
|
||||
if policy == ColorPolicy.COLOR_SWAP_BW:
|
||||
color = swap_bw(color)
|
||||
elif policy == ColorPolicy.COLOR_NEGATIVE:
|
||||
color = invert_color(color)
|
||||
elif policy == ColorPolicy.MONOCHROME_DARK_BG: # [0.3, 1.0]
|
||||
color = color_to_monochrome(color, scale=0.7, offset=0.3)
|
||||
elif policy == ColorPolicy.MONOCHROME_LIGHT_BG: # [0.0, 0.7]
|
||||
color = color_to_monochrome(color, scale=0.7, offset=0.0)
|
||||
elif policy == ColorPolicy.MONOCHROME: # [0.0, 1.0]
|
||||
color = color_to_monochrome(color)
|
||||
elif policy == ColorPolicy.BLACK:
|
||||
color = "#000000"
|
||||
elif policy == ColorPolicy.WHITE:
|
||||
color = "#ffffff"
|
||||
elif policy == ColorPolicy.CUSTOM:
|
||||
fg = custom_color
|
||||
color = fg[:7]
|
||||
alpha = fg[7:9]
|
||||
return color + alpha
|
||||
|
||||
|
||||
def filter_vp_entities(
|
||||
msp: Layout,
|
||||
limits: Sequence[float],
|
||||
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
|
||||
) -> Iterator[DXFGraphic]:
|
||||
"""Yields all DXF entities that need to be processed by the given viewport
|
||||
`limits`. The entities may be partially of even complete outside the viewport.
|
||||
By passing the bounding box cache of the modelspace entities,
|
||||
the function can filter entities outside the viewport to speed up rendering
|
||||
time.
|
||||
|
||||
There are two processing modes for the `bbox_cache`:
|
||||
|
||||
1. The `bbox_cache` is``None``: all entities must be processed,
|
||||
pass through mode
|
||||
2. If the `bbox_cache` is given but does not contain an entity,
|
||||
the bounding box is computed and added to the cache.
|
||||
Even passing in an empty cache can speed up rendering time when
|
||||
multiple viewports need to be processed.
|
||||
|
||||
Args:
|
||||
msp: modelspace layout
|
||||
limits: modelspace limits of the viewport, as tuple (min_x, min_y, max_x, max_y)
|
||||
bbox_cache: the bounding box cache of the modelspace entities
|
||||
|
||||
"""
|
||||
|
||||
# WARNING: this works only with top-view viewports
|
||||
# The current state of the drawing add-on supports only top-view viewports!
|
||||
def is_visible(e):
|
||||
entity_bbox = bbox_cache.get(e)
|
||||
if entity_bbox is None:
|
||||
# compute and add bounding box
|
||||
entity_bbox = ezdxf.bbox.extents((e,), fast=True, cache=bbox_cache)
|
||||
if not entity_bbox.has_data:
|
||||
return True
|
||||
# Check for separating axis:
|
||||
if min_x >= entity_bbox.extmax.x:
|
||||
return False
|
||||
if max_x <= entity_bbox.extmin.x:
|
||||
return False
|
||||
if min_y >= entity_bbox.extmax.y:
|
||||
return False
|
||||
if max_y <= entity_bbox.extmin.y:
|
||||
return False
|
||||
return True
|
||||
|
||||
if bbox_cache is None: # pass through all entities
|
||||
yield from msp
|
||||
return
|
||||
|
||||
min_x, min_y, max_x, max_y = limits
|
||||
if not bbox_cache.has_data:
|
||||
# fill cache at once
|
||||
ezdxf.bbox.extents(msp, fast=True, cache=bbox_cache)
|
||||
|
||||
for entity in msp:
|
||||
if is_visible(entity):
|
||||
yield entity
|
||||
|
||||
|
||||
def prepare_string_for_rendering(text: str, dxftype: str) -> str:
|
||||
assert "\n" not in text, "not a single line of text"
|
||||
if dxftype in {"TEXT", "ATTRIB", "ATTDEF"}:
|
||||
text = replace_non_printable_characters(text, replacement="?")
|
||||
text = text.replace("\t", "?")
|
||||
elif dxftype == "MTEXT":
|
||||
text = replace_non_printable_characters(text, replacement="▯")
|
||||
text = text.replace("\t", " ")
|
||||
else:
|
||||
raise TypeError(dxftype)
|
||||
return text
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,486 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Iterable, no_type_check, Any
|
||||
import copy
|
||||
|
||||
import PIL.Image
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.math import Vec2, BoundingBox2d
|
||||
from ezdxf.colors import RGB
|
||||
from ezdxf.path import Command
|
||||
from ezdxf.version import __version__
|
||||
from ezdxf.lldxf.validator import make_table_key as layer_key
|
||||
|
||||
from .type_hints import Color
|
||||
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
|
||||
from .config import Configuration, LineweightPolicy
|
||||
from .properties import BackendProperties
|
||||
from . import layout, recorder
|
||||
|
||||
is_pymupdf_installed = True
|
||||
pymupdf: Any = None
|
||||
try:
|
||||
import pymupdf # type: ignore[import-untyped, no-redef]
|
||||
except ImportError:
|
||||
print(
|
||||
"Python module PyMuPDF (AGPL!) is required: https://pypi.org/project/PyMuPDF/"
|
||||
)
|
||||
is_pymupdf_installed = False
|
||||
# PyMuPDF docs: https://pymupdf.readthedocs.io/en/latest/
|
||||
|
||||
__all__ = ["PyMuPdfBackend", "is_pymupdf_installed"]
|
||||
|
||||
# PDF units are points (pt), 1 pt is 1/72 of an inch:
|
||||
MM_TO_POINTS = 72.0 / 25.4 # 25.4 mm = 1 inch / 72
|
||||
# psd does not work in PyMuPDF v1.22.3
|
||||
SUPPORTED_IMAGE_FORMATS = ("png", "ppm", "pbm")
|
||||
|
||||
|
||||
class PyMuPdfBackend(recorder.Recorder):
|
||||
"""This backend uses the `PyMuPdf`_ package to create PDF, PNG, PPM and PBM output.
|
||||
This backend support content cropping at page margins.
|
||||
|
||||
PyMuPDF is licensed under the `AGPL`_. Sorry, but it's the best package for the job
|
||||
I've found so far.
|
||||
|
||||
Install package::
|
||||
|
||||
pip install pymupdf
|
||||
|
||||
.. _PyMuPdf: https://pypi.org/project/PyMuPDF/
|
||||
.. _AGPL: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._init_flip_y = True
|
||||
|
||||
def get_replay(
|
||||
self,
|
||||
page: layout.Page,
|
||||
*,
|
||||
settings: layout.Settings = layout.Settings(),
|
||||
render_box: BoundingBox2d | None = None,
|
||||
) -> PyMuPdfRenderBackend:
|
||||
"""Returns the PDF document as bytes.
|
||||
|
||||
Args:
|
||||
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
|
||||
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
|
||||
render_box: set explicit region to render, default is content bounding box
|
||||
"""
|
||||
top_origin = True
|
||||
# This player changes the original recordings!
|
||||
player = self.player()
|
||||
if render_box is None:
|
||||
render_box = player.bbox()
|
||||
|
||||
# the page origin (0, 0) is in the top-left corner.
|
||||
output_layout = layout.Layout(render_box, flip_y=self._init_flip_y)
|
||||
page = output_layout.get_final_page(page, settings)
|
||||
|
||||
# DXF coordinates are mapped to PDF Units in the first quadrant
|
||||
settings = copy.copy(settings)
|
||||
settings.output_coordinate_space = get_coordinate_output_space(page)
|
||||
|
||||
m = output_layout.get_placement_matrix(
|
||||
page, settings=settings, top_origin=top_origin
|
||||
)
|
||||
# transform content to the output coordinates space:
|
||||
player.transform(m)
|
||||
if settings.crop_at_margins:
|
||||
p1, p2 = page.get_margin_rect(top_origin=top_origin) # in mm
|
||||
# scale factor to map page coordinates to output space coordinates:
|
||||
output_scale = settings.page_output_scale_factor(page)
|
||||
max_sagitta = 0.1 * MM_TO_POINTS # curve approximation 0.1 mm
|
||||
# crop content inplace by the margin rect:
|
||||
player.crop_rect(p1 * output_scale, p2 * output_scale, max_sagitta)
|
||||
|
||||
self._init_flip_y = False
|
||||
backend = self.make_backend(page, settings)
|
||||
player.replay(backend)
|
||||
return backend
|
||||
|
||||
def get_pdf_bytes(
|
||||
self,
|
||||
page: layout.Page,
|
||||
*,
|
||||
settings: layout.Settings = layout.Settings(),
|
||||
render_box: BoundingBox2d | None = None,
|
||||
) -> bytes:
|
||||
"""Returns the PDF document as bytes.
|
||||
|
||||
Args:
|
||||
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
|
||||
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
|
||||
render_box: set explicit region to render, default is content bounding box
|
||||
"""
|
||||
backend = self.get_replay(page, settings=settings, render_box=render_box)
|
||||
return backend.get_pdf_bytes()
|
||||
|
||||
def get_pixmap_bytes(
|
||||
self,
|
||||
page: layout.Page,
|
||||
*,
|
||||
fmt="png",
|
||||
settings: layout.Settings = layout.Settings(),
|
||||
dpi: int = 96,
|
||||
alpha=False,
|
||||
render_box: BoundingBox2d | None = None,
|
||||
) -> bytes:
|
||||
"""Returns a pixel image as bytes, supported image formats:
|
||||
|
||||
=== =========================
|
||||
png Portable Network Graphics
|
||||
ppm Portable Pixmap (no alpha channel)
|
||||
pbm Portable Bitmap (no alpha channel)
|
||||
=== =========================
|
||||
|
||||
Args:
|
||||
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
|
||||
fmt: image format
|
||||
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
|
||||
dpi: output resolution in dots per inch
|
||||
alpha: add alpha channel (transparency)
|
||||
render_box: set explicit region to render, default is content bounding box
|
||||
"""
|
||||
if fmt not in SUPPORTED_IMAGE_FORMATS:
|
||||
raise ValueError(f"unsupported image format: '{fmt}'")
|
||||
backend = self.get_replay(page, settings=settings, render_box=render_box)
|
||||
try:
|
||||
pixmap = backend.get_pixmap(dpi=dpi, alpha=alpha)
|
||||
return pixmap.tobytes(output=fmt)
|
||||
except RuntimeError as e:
|
||||
print(f"PyMuPDF Runtime Error: {str(e)}")
|
||||
return b""
|
||||
|
||||
@staticmethod
|
||||
def make_backend(
|
||||
page: layout.Page, settings: layout.Settings
|
||||
) -> PyMuPdfRenderBackend:
|
||||
"""Override this method to use a customized render backend."""
|
||||
return PyMuPdfRenderBackend(page, settings)
|
||||
|
||||
|
||||
def get_coordinate_output_space(page: layout.Page) -> int:
|
||||
page_width_in_pt = int(page.width_in_mm * MM_TO_POINTS)
|
||||
page_height_in_pt = int(page.height_in_mm * MM_TO_POINTS)
|
||||
return max(page_width_in_pt, page_height_in_pt)
|
||||
|
||||
|
||||
class PyMuPdfRenderBackend(BackendInterface):
|
||||
"""Creates the PDF/PNG/PSD/SVG output.
|
||||
|
||||
This backend requires some preliminary work, record the frontend output via the
|
||||
Recorder backend to accomplish the following requirements:
|
||||
|
||||
- Move content in the first quadrant of the coordinate system.
|
||||
- The page is defined by the upper left corner in the origin (0, 0) and
|
||||
the lower right corner at (page-width, page-height)
|
||||
- The output coordinates are floats in 1/72 inch, scale the content appropriately
|
||||
- Replay the recorded output on this backend.
|
||||
|
||||
.. important::
|
||||
|
||||
Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, page: layout.Page, settings: layout.Settings) -> None:
|
||||
assert (
|
||||
is_pymupdf_installed
|
||||
), "Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/"
|
||||
self.doc = pymupdf.open()
|
||||
self.doc.set_metadata(
|
||||
{
|
||||
"producer": f"PyMuPDF {pymupdf.version[0]}",
|
||||
"creator": f"ezdxf {__version__}",
|
||||
}
|
||||
)
|
||||
self.settings = settings
|
||||
self._optional_content_groups: dict[str, int] = {}
|
||||
self._stroke_width_cache: dict[float, float] = {}
|
||||
self._color_cache: dict[str, tuple[float, float, float]] = {}
|
||||
self.page_width_in_pt = int(page.width_in_mm * MM_TO_POINTS)
|
||||
self.page_height_in_pt = int(page.height_in_mm * MM_TO_POINTS)
|
||||
# LineweightPolicy.ABSOLUTE:
|
||||
self.min_lineweight = 0.05 # in mm, set by configure()
|
||||
self.lineweight_scaling = 1.0 # set by configure()
|
||||
self.lineweight_policy = LineweightPolicy.ABSOLUTE # set by configure()
|
||||
|
||||
# when the stroke width is too thin PDF viewers may get confused;
|
||||
self.abs_min_stroke_width = 0.1 # pt == 0.03528mm (arbitrary choice)
|
||||
|
||||
# LineweightPolicy.RELATIVE:
|
||||
# max_stroke_width is determined as a certain percentage of settings.output_coordinate_space
|
||||
self.max_stroke_width: float = max(
|
||||
self.abs_min_stroke_width,
|
||||
int(settings.output_coordinate_space * settings.max_stroke_width),
|
||||
)
|
||||
# min_stroke_width is determined as a certain percentage of max_stroke_width
|
||||
self.min_stroke_width: float = max(
|
||||
self.abs_min_stroke_width,
|
||||
int(self.max_stroke_width * settings.min_stroke_width),
|
||||
)
|
||||
# LineweightPolicy.RELATIVE_FIXED:
|
||||
# all strokes have a fixed stroke-width as a certain percentage of max_stroke_width
|
||||
self.fixed_stroke_width: float = max(
|
||||
self.abs_min_stroke_width,
|
||||
int(self.max_stroke_width * settings.fixed_stroke_width),
|
||||
)
|
||||
self.page = self.doc.new_page(-1, self.page_width_in_pt, self.page_height_in_pt)
|
||||
# The page content is stored in a shared shape:
|
||||
self.content_shape = self.page.new_shape()
|
||||
# see also: https://github.com/pymupdf/PyMuPDF/issues/3800
|
||||
|
||||
def get_pdf_bytes(self) -> bytes:
|
||||
return self.doc.tobytes()
|
||||
|
||||
def get_pixmap(self, dpi: int, alpha=False):
|
||||
return self.page.get_pixmap(dpi=dpi, alpha=alpha)
|
||||
|
||||
def get_svg_image(self) -> str:
|
||||
return self.page.get_svg_image()
|
||||
|
||||
def set_background(self, color: Color) -> None:
|
||||
rgb = self.resolve_color(color)
|
||||
opacity = alpha_to_opacity(color[7:9])
|
||||
if color == (1.0, 1.0, 1.0) or opacity == 0.0:
|
||||
return
|
||||
shape = self.content_shape
|
||||
shape.draw_rect([0, 0, self.page_width_in_pt, self.page_height_in_pt])
|
||||
shape.finish(width=None, color=None, fill=rgb, fill_opacity=opacity)
|
||||
shape.commit()
|
||||
|
||||
def get_optional_content_group(self, layer_name: str) -> int:
|
||||
if not self.settings.output_layers:
|
||||
return 0 # the default value of `oc` when not provided
|
||||
layer_name = layer_key(layer_name)
|
||||
if layer_name not in self._optional_content_groups:
|
||||
self._optional_content_groups[layer_name] = self.doc.add_ocg(
|
||||
name=layer_name,
|
||||
config=-1,
|
||||
on=True,
|
||||
)
|
||||
return self._optional_content_groups[layer_name]
|
||||
|
||||
def finish_line(self, shape, properties: BackendProperties, close: bool) -> None:
|
||||
color = self.resolve_color(properties.color)
|
||||
width = self.resolve_stroke_width(properties.lineweight)
|
||||
shape.finish(
|
||||
width=width,
|
||||
color=color,
|
||||
fill=None,
|
||||
lineJoin=1,
|
||||
lineCap=1,
|
||||
stroke_opacity=alpha_to_opacity(properties.color[7:9]),
|
||||
closePath=close,
|
||||
oc=self.get_optional_content_group(properties.layer),
|
||||
)
|
||||
|
||||
def finish_filling(self, shape, properties: BackendProperties) -> None:
|
||||
shape.finish(
|
||||
width=None,
|
||||
color=None,
|
||||
fill=self.resolve_color(properties.color),
|
||||
fill_opacity=alpha_to_opacity(properties.color[7:9]),
|
||||
lineJoin=1,
|
||||
lineCap=1,
|
||||
closePath=True,
|
||||
even_odd=True,
|
||||
oc=self.get_optional_content_group(properties.layer),
|
||||
)
|
||||
|
||||
def resolve_color(self, color: Color) -> tuple[float, float, float]:
|
||||
key = color[:7]
|
||||
try:
|
||||
return self._color_cache[key]
|
||||
except KeyError:
|
||||
pass
|
||||
color_floats = RGB.from_hex(color).to_floats()
|
||||
self._color_cache[key] = color_floats
|
||||
return color_floats
|
||||
|
||||
def resolve_stroke_width(self, width: float) -> float:
|
||||
try:
|
||||
return self._stroke_width_cache[width]
|
||||
except KeyError:
|
||||
pass
|
||||
stroke_width = self.min_stroke_width
|
||||
if self.lineweight_policy == LineweightPolicy.ABSOLUTE:
|
||||
stroke_width = ( # in points (pt) = 1/72 inch
|
||||
max(self.min_lineweight, width) * MM_TO_POINTS * self.lineweight_scaling
|
||||
)
|
||||
elif self.lineweight_policy == LineweightPolicy.RELATIVE:
|
||||
stroke_width = map_lineweight_to_stroke_width(
|
||||
width, self.min_stroke_width, self.max_stroke_width
|
||||
)
|
||||
stroke_width = max(self.abs_min_stroke_width, stroke_width)
|
||||
self._stroke_width_cache[width] = stroke_width
|
||||
return stroke_width
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
shape = self.content_shape
|
||||
pos = Vec2(pos)
|
||||
shape.draw_line(pos, pos)
|
||||
self.finish_line(shape, properties, close=False)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
shape = self.content_shape
|
||||
shape.draw_line(Vec2(start), Vec2(end))
|
||||
self.finish_line(shape, properties, close=False)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
shape = self.content_shape
|
||||
for start, end in lines:
|
||||
shape.draw_line(start, end)
|
||||
self.finish_line(shape, properties, close=False)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
if len(path) == 0:
|
||||
return
|
||||
shape = self.content_shape
|
||||
add_path_to_shape(shape, path, close=False)
|
||||
self.finish_line(shape, properties, close=False)
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
shape = self.content_shape
|
||||
for p in paths:
|
||||
add_path_to_shape(shape, p, close=True)
|
||||
self.finish_filling(shape, properties)
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
vertices = points.to_list()
|
||||
if len(vertices) < 3:
|
||||
return
|
||||
# pymupdf >= 1.23.19 does not accept Vec2() instances
|
||||
# input coordinates are page coordinates in pdf units
|
||||
shape = self.content_shape
|
||||
shape.draw_polyline(vertices)
|
||||
self.finish_filling(shape, properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
transform = image_data.transform
|
||||
image = image_data.image
|
||||
height, width, depth = image.shape
|
||||
assert depth == 4
|
||||
|
||||
corners = list(
|
||||
transform.transform_vertices(
|
||||
[Vec2(0, 0), Vec2(width, 0), Vec2(width, height), Vec2(0, height)]
|
||||
)
|
||||
)
|
||||
xs = [p.x for p in corners]
|
||||
ys = [p.y for p in corners]
|
||||
r = pymupdf.Rect((min(xs), min(ys)), (max(xs), max(ys)))
|
||||
|
||||
# translation and non-uniform scale are handled by having the image stretch to fill the given rect.
|
||||
angle = (corners[1] - corners[0]).angle_deg
|
||||
need_rotate = not math.isclose(angle, 0.0)
|
||||
# already mirroring once to go from pixels (+y down) to wcs (+y up)
|
||||
# so a positive determinant means an additional reflection
|
||||
need_flip = transform.determinant() > 0
|
||||
|
||||
if need_rotate or need_flip:
|
||||
pil_image = PIL.Image.fromarray(image, mode="RGBA")
|
||||
if need_flip:
|
||||
pil_image = pil_image.transpose(PIL.Image.Transpose.FLIP_TOP_BOTTOM)
|
||||
if need_rotate:
|
||||
pil_image = pil_image.rotate(
|
||||
-angle,
|
||||
resample=PIL.Image.Resampling.BICUBIC,
|
||||
expand=True,
|
||||
fillcolor=(0, 0, 0, 0),
|
||||
)
|
||||
image = np.asarray(pil_image)
|
||||
height, width, depth = image.shape
|
||||
|
||||
pixmap = pymupdf.Pixmap(
|
||||
pymupdf.Colorspace(pymupdf.CS_RGB), width, height, bytes(image.data), True
|
||||
)
|
||||
# TODO: could improve by caching and re-using xrefs. If a document contains many
|
||||
# identical images redundant copies will be stored for each one
|
||||
self.page.insert_image(
|
||||
r,
|
||||
keep_proportion=False,
|
||||
pixmap=pixmap,
|
||||
oc=self.get_optional_content_group(properties.layer),
|
||||
)
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
self.lineweight_policy = config.lineweight_policy
|
||||
if config.min_lineweight:
|
||||
# config.min_lineweight in 1/300 inch!
|
||||
min_lineweight_mm = config.min_lineweight * 25.4 / 300
|
||||
self.min_lineweight = max(0.05, min_lineweight_mm)
|
||||
self.lineweight_scaling = config.lineweight_scaling
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def finalize(self) -> None:
|
||||
self.content_shape.commit()
|
||||
|
||||
def enter_entity(self, entity, properties) -> None:
|
||||
pass
|
||||
|
||||
def exit_entity(self, entity) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@no_type_check
|
||||
def add_path_to_shape(shape, path: BkPath2d, close: bool) -> None:
|
||||
start = path.start
|
||||
sub_path_start = start
|
||||
last_point = start
|
||||
for command in path.commands():
|
||||
end = command.end
|
||||
if command.type == Command.MOVE_TO:
|
||||
if close and not sub_path_start.isclose(end):
|
||||
shape.draw_line(start, sub_path_start)
|
||||
sub_path_start = end
|
||||
elif command.type == Command.LINE_TO:
|
||||
shape.draw_line(start, end)
|
||||
elif command.type == Command.CURVE3_TO:
|
||||
shape.draw_curve(start, command.ctrl, end)
|
||||
elif command.type == Command.CURVE4_TO:
|
||||
shape.draw_bezier(start, command.ctrl1, command.ctrl2, end)
|
||||
start = end
|
||||
last_point = end
|
||||
if close and not sub_path_start.isclose(last_point):
|
||||
shape.draw_line(last_point, sub_path_start)
|
||||
|
||||
|
||||
def map_lineweight_to_stroke_width(
|
||||
lineweight: float,
|
||||
min_stroke_width: float,
|
||||
max_stroke_width: float,
|
||||
min_lineweight=0.05, # defined by DXF
|
||||
max_lineweight=2.11, # defined by DXF
|
||||
) -> float:
|
||||
"""Map the DXF lineweight in mm to stroke-width in viewBox coordinates."""
|
||||
lineweight = max(min(lineweight, max_lineweight), min_lineweight) - min_lineweight
|
||||
factor = (max_stroke_width - min_stroke_width) / (max_lineweight - min_lineweight)
|
||||
return min_stroke_width + round(lineweight * factor, 1)
|
||||
|
||||
|
||||
def alpha_to_opacity(alpha: str) -> float:
|
||||
# stroke-opacity: 0.0 = transparent; 1.0 = opaque
|
||||
# alpha: "00" = transparent; "ff" = opaque
|
||||
if len(alpha):
|
||||
try:
|
||||
return int(alpha, 16) / 255
|
||||
except ValueError:
|
||||
pass
|
||||
return 1.0
|
||||
@@ -0,0 +1,314 @@
|
||||
# Copyright (c) 2020-2023, Matthew Broadway
|
||||
# License: MIT License
|
||||
# mypy: ignore_errors=True
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Iterable
|
||||
import abc
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.addons.xqt import QtCore as qc, QtGui as qg, QtWidgets as qw
|
||||
from ezdxf.addons.drawing.backend import Backend, BkPath2d, BkPoints2d, ImageData
|
||||
from ezdxf.addons.drawing.config import Configuration
|
||||
from ezdxf.addons.drawing.type_hints import Color
|
||||
from ezdxf.addons.drawing.properties import BackendProperties
|
||||
from ezdxf.math import Vec2, Matrix44
|
||||
from ezdxf.npshapes import to_qpainter_path
|
||||
|
||||
|
||||
class _Point(qw.QAbstractGraphicsShapeItem):
|
||||
"""A dimensionless point which is drawn 'cosmetically' (scale depends on
|
||||
view)
|
||||
"""
|
||||
|
||||
def __init__(self, x: float, y: float, brush: qg.QBrush):
|
||||
super().__init__()
|
||||
self.location = qc.QPointF(x, y)
|
||||
self.radius = 1.0
|
||||
self.setPen(qg.QPen(qc.Qt.NoPen))
|
||||
self.setBrush(brush)
|
||||
|
||||
def paint(
|
||||
self,
|
||||
painter: qg.QPainter,
|
||||
option: qw.QStyleOptionGraphicsItem,
|
||||
widget: Optional[qw.QWidget] = None,
|
||||
) -> None:
|
||||
view_scale = _get_x_scale(painter.transform())
|
||||
radius = self.radius / view_scale
|
||||
painter.setBrush(self.brush())
|
||||
painter.setPen(qc.Qt.NoPen)
|
||||
painter.drawEllipse(self.location, radius, radius)
|
||||
|
||||
def boundingRect(self) -> qc.QRectF:
|
||||
return qc.QRectF(self.location, qc.QSizeF(1, 1))
|
||||
|
||||
|
||||
# The key used to store the dxf entity corresponding to each graphics element
|
||||
CorrespondingDXFEntity = qc.Qt.UserRole + 0 # type: ignore
|
||||
CorrespondingDXFParentStack = qc.Qt.UserRole + 1 # type: ignore
|
||||
|
||||
|
||||
class _PyQtBackend(Backend):
|
||||
"""
|
||||
Abstract PyQt backend which uses the :mod:`PySide6` package to implement an
|
||||
interactive viewer. The :mod:`PyQt5` package can be used as fallback if the
|
||||
:mod:`PySide6` package is not available.
|
||||
"""
|
||||
|
||||
def __init__(self, scene: qw.QGraphicsScene):
|
||||
super().__init__()
|
||||
self._scene = scene
|
||||
self._color_cache: dict[Color, qg.QColor] = {}
|
||||
self._no_line = qg.QPen(qc.Qt.NoPen)
|
||||
self._no_fill = qg.QBrush(qc.Qt.NoBrush)
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
if config.min_lineweight is None:
|
||||
config = config.with_changes(min_lineweight=0.24)
|
||||
super().configure(config)
|
||||
|
||||
def set_scene(self, scene: qw.QGraphicsScene) -> None:
|
||||
self._scene = scene
|
||||
|
||||
def _add_item(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
|
||||
self.set_item_data(item, entity_handle)
|
||||
self._scene.addItem(item)
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
|
||||
...
|
||||
|
||||
def _get_color(self, color: Color) -> qg.QColor:
|
||||
try:
|
||||
return self._color_cache[color]
|
||||
except KeyError:
|
||||
pass
|
||||
if len(color) == 7:
|
||||
qt_color = qg.QColor(color) # '#RRGGBB'
|
||||
elif len(color) == 9:
|
||||
rgb = color[1:7]
|
||||
alpha = color[7:9]
|
||||
qt_color = qg.QColor(f"#{alpha}{rgb}") # '#AARRGGBB'
|
||||
else:
|
||||
raise TypeError(color)
|
||||
|
||||
self._color_cache[color] = qt_color
|
||||
return qt_color
|
||||
|
||||
def _get_pen(self, properties: BackendProperties) -> qg.QPen:
|
||||
"""Returns a cosmetic pen with applied lineweight but without line type
|
||||
support.
|
||||
"""
|
||||
px = properties.lineweight / 0.3527 * self.config.lineweight_scaling
|
||||
pen = qg.QPen(self._get_color(properties.color), px)
|
||||
# Use constant width in pixel:
|
||||
pen.setCosmetic(True)
|
||||
pen.setJoinStyle(qc.Qt.RoundJoin)
|
||||
return pen
|
||||
|
||||
def _get_fill_brush(self, color: Color) -> qg.QBrush:
|
||||
return qg.QBrush(self._get_color(color), qc.Qt.SolidPattern) # type: ignore
|
||||
|
||||
def set_background(self, color: Color):
|
||||
self._scene.setBackgroundBrush(qg.QBrush(self._get_color(color)))
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
"""Draw a real dimensionless point."""
|
||||
brush = self._get_fill_brush(properties.color)
|
||||
item = _Point(pos.x, pos.y, brush)
|
||||
self._add_item(item, properties.handle)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
# PyQt draws a long line for a zero-length line:
|
||||
if start.isclose(end):
|
||||
self.draw_point(start, properties)
|
||||
else:
|
||||
item = qw.QGraphicsLineItem(start.x, start.y, end.x, end.y)
|
||||
item.setPen(self._get_pen(properties))
|
||||
self._add_item(item, properties.handle)
|
||||
|
||||
def draw_solid_lines(
|
||||
self,
|
||||
lines: Iterable[tuple[Vec2, Vec2]],
|
||||
properties: BackendProperties,
|
||||
):
|
||||
"""Fast method to draw a bunch of solid lines with the same properties."""
|
||||
pen = self._get_pen(properties)
|
||||
add_line = self._add_item
|
||||
for s, e in lines:
|
||||
if s.isclose(e):
|
||||
self.draw_point(s, properties)
|
||||
else:
|
||||
item = qw.QGraphicsLineItem(s.x, s.y, e.x, e.y)
|
||||
item.setPen(pen)
|
||||
add_line(item, properties.handle)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
if len(path) == 0:
|
||||
return
|
||||
item = qw.QGraphicsPathItem(to_qpainter_path([path]))
|
||||
item.setPen(self._get_pen(properties))
|
||||
item.setBrush(self._no_fill)
|
||||
self._add_item(item, properties.handle)
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
# Default fill rule is OddEvenFill! Detecting the path orientation is not
|
||||
# necessary!
|
||||
_paths = list(paths)
|
||||
if len(_paths) == 0:
|
||||
return
|
||||
item = _CosmeticPath(to_qpainter_path(_paths))
|
||||
item.setPen(self._get_pen(properties))
|
||||
item.setBrush(self._get_fill_brush(properties.color))
|
||||
self._add_item(item, properties.handle)
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
brush = self._get_fill_brush(properties.color)
|
||||
polygon = qg.QPolygonF()
|
||||
for p in points.vertices():
|
||||
polygon.append(qc.QPointF(p.x, p.y))
|
||||
item = _CosmeticPolygon(polygon)
|
||||
item.setPen(self._no_line)
|
||||
item.setBrush(brush)
|
||||
self._add_item(item, properties.handle)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
image = image_data.image
|
||||
transform = image_data.transform
|
||||
height, width, depth = image.shape
|
||||
assert depth == 4
|
||||
bytes_per_row = width * depth
|
||||
image = np.ascontiguousarray(np.flip(image, axis=0))
|
||||
pixmap = qg.QPixmap(
|
||||
qg.QImage(
|
||||
image.data,
|
||||
width,
|
||||
height,
|
||||
bytes_per_row,
|
||||
qg.QImage.Format.Format_RGBA8888,
|
||||
)
|
||||
)
|
||||
item = qw.QGraphicsPixmapItem()
|
||||
item.setPixmap(pixmap)
|
||||
item.setTransformationMode(qc.Qt.TransformationMode.SmoothTransformation)
|
||||
item.setTransform(_matrix_to_qtransform(transform))
|
||||
self._add_item(item, properties.handle)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._scene.clear()
|
||||
|
||||
def finalize(self) -> None:
|
||||
super().finalize()
|
||||
self._scene.setSceneRect(self._scene.itemsBoundingRect())
|
||||
|
||||
|
||||
class PyQtBackend(_PyQtBackend):
|
||||
"""
|
||||
Backend which uses the :mod:`PySide6` package to implement an interactive
|
||||
viewer. The :mod:`PyQt5` package can be used as fallback if the :mod:`PySide6`
|
||||
package is not available.
|
||||
|
||||
Args:
|
||||
scene: drawing canvas of type :class:`QtWidgets.QGraphicsScene`,
|
||||
if ``None`` a new canvas will be created
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scene: Optional[qw.QGraphicsScene] = None,
|
||||
):
|
||||
super().__init__(scene or qw.QGraphicsScene())
|
||||
|
||||
# This implementation keeps all virtual entities alive by attaching references
|
||||
# to entities to the graphic scene items.
|
||||
|
||||
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
|
||||
parent_stack = tuple(e for e, props in self.entity_stack[:-1])
|
||||
current_entity = self.current_entity
|
||||
item.setData(CorrespondingDXFEntity, current_entity)
|
||||
item.setData(CorrespondingDXFParentStack, parent_stack)
|
||||
|
||||
|
||||
class PyQtPlaybackBackend(_PyQtBackend):
|
||||
"""
|
||||
Backend which uses the :mod:`PySide6` package to implement an interactive
|
||||
viewer. The :mod:`PyQt5` package can be used as fallback if the :mod:`PySide6`
|
||||
package is not available.
|
||||
|
||||
This backend can be used a playback backend for the :meth:`replay` method of the
|
||||
:class:`Player` class
|
||||
|
||||
Args:
|
||||
scene: drawing canvas of type :class:`QtWidgets.QGraphicsScene`,
|
||||
if ``None`` a new canvas will be created
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scene: Optional[qw.QGraphicsScene] = None,
|
||||
):
|
||||
super().__init__(scene or qw.QGraphicsScene())
|
||||
|
||||
# The backend recorder does not record enter_entity() and exit_entity() events.
|
||||
# This implementation attaches only entity handles (str) to the graphic scene items.
|
||||
# Each item references the top level entity e.g. all items of a block reference
|
||||
# references the handle of the INSERT entity.
|
||||
|
||||
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
|
||||
item.setData(CorrespondingDXFEntity, entity_handle)
|
||||
|
||||
|
||||
class _CosmeticPath(qw.QGraphicsPathItem):
|
||||
def paint(
|
||||
self,
|
||||
painter: qg.QPainter,
|
||||
option: qw.QStyleOptionGraphicsItem,
|
||||
widget: Optional[qw.QWidget] = None,
|
||||
) -> None:
|
||||
_set_cosmetic_brush(self, painter)
|
||||
super().paint(painter, option, widget)
|
||||
|
||||
|
||||
class _CosmeticPolygon(qw.QGraphicsPolygonItem):
|
||||
def paint(
|
||||
self,
|
||||
painter: qg.QPainter,
|
||||
option: qw.QStyleOptionGraphicsItem,
|
||||
widget: Optional[qw.QWidget] = None,
|
||||
) -> None:
|
||||
_set_cosmetic_brush(self, painter)
|
||||
super().paint(painter, option, widget)
|
||||
|
||||
|
||||
def _set_cosmetic_brush(
|
||||
item: qw.QAbstractGraphicsShapeItem, painter: qg.QPainter
|
||||
) -> None:
|
||||
"""like a cosmetic pen, this sets the brush pattern to appear the same independent of the view"""
|
||||
brush = item.brush()
|
||||
# scale by -1 in y because the view is always mirrored in y and undoing the view transformation entirely would make
|
||||
# the hatch mirrored w.r.t the view
|
||||
brush.setTransform(painter.transform().inverted()[0].scale(1, -1)) # type: ignore
|
||||
item.setBrush(brush)
|
||||
|
||||
|
||||
def _get_x_scale(t: qg.QTransform) -> float:
|
||||
return math.sqrt(t.m11() * t.m11() + t.m21() * t.m21())
|
||||
|
||||
|
||||
def _matrix_to_qtransform(matrix: Matrix44) -> qg.QTransform:
|
||||
"""Qt also uses row-vectors so the translation elements are placed in the
|
||||
bottom row.
|
||||
|
||||
This is only a simple conversion which assumes that although the
|
||||
transformation is 4x4,it does not involve the z axis.
|
||||
|
||||
A more correct transformation could be implemented like so:
|
||||
https://stackoverflow.com/questions/10629737/convert-3d-4x4-rotation-matrix-into-2d
|
||||
"""
|
||||
return qg.QTransform(*matrix.get_2d_transformation())
|
||||
@@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2020-2023, Matthew Broadway
|
||||
# License: MIT License
|
||||
# mypy: ignore_errors=True
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence, Set, Optional
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
|
||||
from ezdxf.addons.xqt import QtWidgets as qw, QtCore as qc, QtGui as qg
|
||||
from ezdxf.addons.xqt import Slot, QAction, Signal
|
||||
|
||||
import ezdxf
|
||||
import ezdxf.bbox
|
||||
from ezdxf import recover
|
||||
from ezdxf.addons import odafc
|
||||
from ezdxf.addons.drawing import Frontend, RenderContext
|
||||
from ezdxf.addons.drawing.config import Configuration
|
||||
|
||||
from ezdxf.addons.drawing.properties import (
|
||||
is_dark_color,
|
||||
set_layers_state,
|
||||
LayerProperties,
|
||||
)
|
||||
from ezdxf.addons.drawing.pyqt import (
|
||||
_get_x_scale,
|
||||
PyQtBackend,
|
||||
CorrespondingDXFEntity,
|
||||
CorrespondingDXFParentStack,
|
||||
)
|
||||
from ezdxf.audit import Auditor
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.entities import DXFGraphic, DXFEntity
|
||||
from ezdxf.layouts import Layout
|
||||
from ezdxf.lldxf.const import DXFStructureError
|
||||
|
||||
|
||||
class CADGraphicsView(qw.QGraphicsView):
|
||||
closing = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
view_buffer: float = 0.2,
|
||||
zoom_per_scroll_notch: float = 0.2,
|
||||
loading_overlay: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self._zoom = 1.0
|
||||
self._default_zoom = 1.0
|
||||
self._zoom_limits = (0.5, 100)
|
||||
self._zoom_per_scroll_notch = zoom_per_scroll_notch
|
||||
self._view_buffer = view_buffer
|
||||
self._loading_overlay = loading_overlay
|
||||
self._is_loading = False
|
||||
|
||||
self.setTransformationAnchor(qw.QGraphicsView.AnchorUnderMouse)
|
||||
self.setResizeAnchor(qw.QGraphicsView.AnchorUnderMouse)
|
||||
self.setVerticalScrollBarPolicy(qc.Qt.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(qc.Qt.ScrollBarAlwaysOff)
|
||||
self.setDragMode(qw.QGraphicsView.ScrollHandDrag)
|
||||
self.setFrameShape(qw.QFrame.NoFrame)
|
||||
self.setRenderHints(
|
||||
qg.QPainter.Antialiasing
|
||||
| qg.QPainter.TextAntialiasing
|
||||
| qg.QPainter.SmoothPixmapTransform
|
||||
)
|
||||
|
||||
self.setScene(qw.QGraphicsScene())
|
||||
self.scale(1, -1) # so that +y is up
|
||||
|
||||
def closeEvent(self, event: qg.QCloseEvent) -> None:
|
||||
super().closeEvent(event)
|
||||
self.closing.emit()
|
||||
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
def begin_loading(self):
|
||||
self._is_loading = True
|
||||
self.scene().invalidate(qc.QRectF(), qw.QGraphicsScene.AllLayers)
|
||||
qw.QApplication.processEvents()
|
||||
|
||||
def end_loading(self, new_scene: qw.QGraphicsScene):
|
||||
self.setScene(new_scene)
|
||||
self._is_loading = False
|
||||
self.buffer_scene_rect()
|
||||
self.scene().invalidate(qc.QRectF(), qw.QGraphicsScene.AllLayers)
|
||||
|
||||
def buffer_scene_rect(self):
|
||||
scene = self.scene()
|
||||
r = scene.sceneRect()
|
||||
bx, by = (
|
||||
r.width() * self._view_buffer / 2,
|
||||
r.height() * self._view_buffer / 2,
|
||||
)
|
||||
scene.setSceneRect(r.adjusted(-bx, -by, bx, by))
|
||||
|
||||
def fit_to_scene(self):
|
||||
self.fitInView(self.sceneRect(), qc.Qt.KeepAspectRatio)
|
||||
self._default_zoom = _get_x_scale(self.transform())
|
||||
self._zoom = 1
|
||||
|
||||
def _get_zoom_amount(self) -> float:
|
||||
return _get_x_scale(self.transform()) / self._default_zoom
|
||||
|
||||
def wheelEvent(self, event: qg.QWheelEvent) -> None:
|
||||
# dividing by 120 gets number of notches on a typical scroll wheel.
|
||||
# See QWheelEvent documentation
|
||||
delta_notches = event.angleDelta().y() / 120
|
||||
direction = math.copysign(1, delta_notches)
|
||||
factor = (1.0 + self._zoom_per_scroll_notch * direction) ** abs(delta_notches)
|
||||
resulting_zoom = self._zoom * factor
|
||||
if resulting_zoom < self._zoom_limits[0]:
|
||||
factor = self._zoom_limits[0] / self._zoom
|
||||
elif resulting_zoom > self._zoom_limits[1]:
|
||||
factor = self._zoom_limits[1] / self._zoom
|
||||
self.scale(factor, factor)
|
||||
self._zoom *= factor
|
||||
|
||||
def save_view(self) -> SavedView:
|
||||
return SavedView(
|
||||
self.transform(),
|
||||
self._default_zoom,
|
||||
self._zoom,
|
||||
self.horizontalScrollBar().value(),
|
||||
self.verticalScrollBar().value(),
|
||||
)
|
||||
|
||||
def restore_view(self, view: SavedView):
|
||||
self.setTransform(view.transform)
|
||||
self._default_zoom = view.default_zoom
|
||||
self._zoom = view.zoom
|
||||
self.horizontalScrollBar().setValue(view.x)
|
||||
self.verticalScrollBar().setValue(view.y)
|
||||
|
||||
def drawForeground(self, painter: qg.QPainter, rect: qc.QRectF) -> None:
|
||||
if self._is_loading and self._loading_overlay:
|
||||
painter.save()
|
||||
painter.fillRect(rect, qg.QColor("#aa000000"))
|
||||
painter.setWorldMatrixEnabled(False)
|
||||
r = self.viewport().rect()
|
||||
painter.setBrush(qc.Qt.NoBrush)
|
||||
painter.setPen(qc.Qt.white)
|
||||
painter.drawText(r.center(), "Loading...")
|
||||
painter.restore()
|
||||
|
||||
|
||||
class SavedView:
|
||||
def __init__(
|
||||
self, transform: qg.QTransform, default_zoom: float, zoom: float, x: int, y: int
|
||||
):
|
||||
self.transform = transform
|
||||
self.default_zoom = default_zoom
|
||||
self.zoom = zoom
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
|
||||
class CADGraphicsViewWithOverlay(CADGraphicsView):
|
||||
mouse_moved = Signal(qc.QPointF)
|
||||
element_hovered = Signal(object, int)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._selected_items: list[qw.QGraphicsItem] = []
|
||||
self._selected_index = None
|
||||
self._mark_selection = True
|
||||
|
||||
@property
|
||||
def current_hovered_element(self) -> Optional[DXFEntity]:
|
||||
if self._selected_items:
|
||||
graphics_item = self._selected_items[self._selected_index]
|
||||
dxf_entity = graphics_item.data(CorrespondingDXFEntity)
|
||||
return dxf_entity
|
||||
else:
|
||||
return None
|
||||
|
||||
def clear(self):
|
||||
super().clear()
|
||||
self._selected_items = None
|
||||
self._selected_index = None
|
||||
|
||||
def begin_loading(self):
|
||||
self.clear()
|
||||
super().begin_loading()
|
||||
|
||||
def drawForeground(self, painter: qg.QPainter, rect: qc.QRectF) -> None:
|
||||
super().drawForeground(painter, rect)
|
||||
if self._selected_items and self._mark_selection:
|
||||
item = self._selected_items[self._selected_index]
|
||||
r = item.sceneTransform().mapRect(item.boundingRect())
|
||||
painter.fillRect(r, qg.QColor(0, 255, 0, 100))
|
||||
|
||||
def mouseMoveEvent(self, event: qg.QMouseEvent) -> None:
|
||||
super().mouseMoveEvent(event)
|
||||
pos = self.mapToScene(event.pos())
|
||||
self.mouse_moved.emit(pos)
|
||||
selected_items = self.scene().items(pos)
|
||||
if selected_items != self._selected_items:
|
||||
self._selected_items = selected_items
|
||||
self._selected_index = 0 if self._selected_items else None
|
||||
self._emit_selected()
|
||||
|
||||
def mouseReleaseEvent(self, event: qg.QMouseEvent) -> None:
|
||||
super().mouseReleaseEvent(event)
|
||||
if event.button() == qc.Qt.LeftButton and self._selected_items:
|
||||
self._selected_index = (self._selected_index + 1) % len(
|
||||
self._selected_items
|
||||
)
|
||||
self._emit_selected()
|
||||
|
||||
def _emit_selected(self):
|
||||
self.element_hovered.emit(self._selected_items, self._selected_index)
|
||||
self.scene().invalidate(self.sceneRect(), qw.QGraphicsScene.ForegroundLayer)
|
||||
|
||||
def toggle_selection_marker(self):
|
||||
self._mark_selection = not self._mark_selection
|
||||
|
||||
|
||||
class CADWidget(qw.QWidget):
|
||||
def __init__(self, view: CADGraphicsView, config: Configuration = Configuration()):
|
||||
super().__init__()
|
||||
layout = qw.QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(view)
|
||||
self.setLayout(layout)
|
||||
self._view = view
|
||||
self._view.closing.connect(self.close)
|
||||
self._config = config
|
||||
self._bbox_cache = ezdxf.bbox.Cache()
|
||||
self._doc: Drawing = None # type: ignore
|
||||
self._render_context: RenderContext = None # type: ignore
|
||||
self._visible_layers: set[str] = set()
|
||||
self._current_layout: str = "Model"
|
||||
self._reset_backend()
|
||||
|
||||
def _reset_backend(self):
|
||||
# clear caches
|
||||
self._backend = PyQtBackend()
|
||||
|
||||
@property
|
||||
def doc(self) -> Drawing:
|
||||
return self._doc
|
||||
|
||||
@property
|
||||
def view(self) -> CADGraphicsView:
|
||||
return self._view
|
||||
|
||||
@property
|
||||
def render_context(self) -> RenderContext:
|
||||
return self._render_context
|
||||
|
||||
@property
|
||||
def current_layout(self) -> str:
|
||||
return self._current_layout
|
||||
|
||||
def set_document(
|
||||
self,
|
||||
document: Drawing,
|
||||
*,
|
||||
layout: str = "Model",
|
||||
draw: bool = True,
|
||||
):
|
||||
self._doc = document
|
||||
# initialize bounding box cache for faste paperspace drawing
|
||||
self._bbox_cache = ezdxf.bbox.Cache()
|
||||
self._render_context = self._make_render_context(document)
|
||||
self._reset_backend()
|
||||
self._visible_layers = set()
|
||||
self._current_layout = None
|
||||
if draw:
|
||||
self.draw_layout(layout)
|
||||
|
||||
def set_visible_layers(self, layers: Set[str]) -> None:
|
||||
self._visible_layers = layers
|
||||
self.draw_layout(self._current_layout, reset_view=False)
|
||||
|
||||
def _make_render_context(self, doc: Drawing) -> RenderContext:
|
||||
def update_layers_state(layers: Sequence[LayerProperties]):
|
||||
if self._visible_layers:
|
||||
set_layers_state(layers, self._visible_layers, state=True)
|
||||
|
||||
render_context = RenderContext(doc)
|
||||
render_context.set_layer_properties_override(update_layers_state)
|
||||
return render_context
|
||||
|
||||
def draw_layout(
|
||||
self,
|
||||
layout_name: str,
|
||||
reset_view: bool = True,
|
||||
):
|
||||
self._current_layout = layout_name
|
||||
self._view.begin_loading()
|
||||
new_scene = qw.QGraphicsScene()
|
||||
self._backend.set_scene(new_scene)
|
||||
layout = self._doc.layout(layout_name)
|
||||
self._update_render_context(layout)
|
||||
try:
|
||||
self._create_frontend().draw_layout(layout)
|
||||
finally:
|
||||
self._backend.finalize()
|
||||
self._view.end_loading(new_scene)
|
||||
self._view.buffer_scene_rect()
|
||||
if reset_view:
|
||||
self._view.fit_to_scene()
|
||||
|
||||
def _create_frontend(self) -> Frontend:
|
||||
return Frontend(
|
||||
ctx=self._render_context,
|
||||
out=self._backend,
|
||||
config=self._config,
|
||||
bbox_cache=self._bbox_cache,
|
||||
)
|
||||
|
||||
def _update_render_context(self, layout: Layout) -> None:
|
||||
assert self._render_context is not None
|
||||
self._render_context.set_current_layout(layout)
|
||||
|
||||
|
||||
class CADViewer(qw.QMainWindow):
|
||||
def __init__(self, cad: Optional[CADWidget] = None):
|
||||
super().__init__()
|
||||
self._doc: Optional[Drawing] = None
|
||||
if cad is None:
|
||||
self._cad = CADWidget(CADGraphicsViewWithOverlay(), config=Configuration())
|
||||
else:
|
||||
self._cad = cad
|
||||
self._view = self._cad.view
|
||||
|
||||
if isinstance(self._view, CADGraphicsViewWithOverlay):
|
||||
self._view.element_hovered.connect(self._on_element_hovered)
|
||||
self._view.mouse_moved.connect(self._on_mouse_moved)
|
||||
|
||||
menu = self.menuBar()
|
||||
select_doc_action = QAction("Select Document", self)
|
||||
select_doc_action.triggered.connect(self._select_doc)
|
||||
menu.addAction(select_doc_action)
|
||||
self.select_layout_menu = menu.addMenu("Select Layout")
|
||||
|
||||
toggle_sidebar_action = QAction("Toggle Sidebar", self)
|
||||
toggle_sidebar_action.triggered.connect(self._toggle_sidebar)
|
||||
menu.addAction(toggle_sidebar_action)
|
||||
|
||||
toggle_selection_marker_action = QAction("Toggle Entity Marker", self)
|
||||
toggle_selection_marker_action.triggered.connect(self._toggle_selection_marker)
|
||||
menu.addAction(toggle_selection_marker_action)
|
||||
|
||||
self.reload_menu = menu.addMenu("Reload")
|
||||
reload_action = QAction("Reload", self)
|
||||
reload_action.setShortcut(qg.QKeySequence("F5"))
|
||||
reload_action.triggered.connect(self._reload)
|
||||
self.reload_menu.addAction(reload_action)
|
||||
self.keep_view_action = QAction("Keep View", self)
|
||||
self.keep_view_action.setCheckable(True)
|
||||
self.keep_view_action.setChecked(True)
|
||||
self.reload_menu.addAction(self.keep_view_action)
|
||||
watch_action = QAction("Watch", self)
|
||||
watch_action.setCheckable(True)
|
||||
watch_action.toggled.connect(self._toggle_watch)
|
||||
self.reload_menu.addAction(watch_action)
|
||||
self._watch_timer = qc.QTimer()
|
||||
self._watch_timer.setInterval(50)
|
||||
self._watch_timer.timeout.connect(self._check_watch)
|
||||
self._watch_mtime = None
|
||||
|
||||
self.sidebar = qw.QSplitter(qc.Qt.Vertical)
|
||||
self.layers = qw.QListWidget()
|
||||
self.layers.setStyleSheet(
|
||||
"QListWidget {font-size: 12pt;} "
|
||||
"QCheckBox {font-size: 12pt; padding-left: 5px;}"
|
||||
)
|
||||
self.sidebar.addWidget(self.layers)
|
||||
info_container = qw.QWidget()
|
||||
info_layout = qw.QVBoxLayout()
|
||||
info_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.selected_info = qw.QPlainTextEdit()
|
||||
self.selected_info.setReadOnly(True)
|
||||
info_layout.addWidget(self.selected_info)
|
||||
self.mouse_pos = qw.QLabel()
|
||||
info_layout.addWidget(self.mouse_pos)
|
||||
info_container.setLayout(info_layout)
|
||||
self.sidebar.addWidget(info_container)
|
||||
|
||||
container = qw.QSplitter()
|
||||
self.setCentralWidget(container)
|
||||
container.addWidget(self._cad)
|
||||
container.addWidget(self.sidebar)
|
||||
container.setCollapsible(0, False)
|
||||
container.setCollapsible(1, True)
|
||||
w = container.width()
|
||||
container.setSizes([int(3 * w / 4), int(w / 4)])
|
||||
self.setWindowTitle("CAD Viewer")
|
||||
self.resize(1600, 900)
|
||||
self.show()
|
||||
|
||||
@staticmethod
|
||||
def from_config(config: Configuration) -> CADViewer:
|
||||
return CADViewer(cad=CADWidget(CADGraphicsViewWithOverlay(), config=config))
|
||||
|
||||
def _create_cad_widget(self):
|
||||
self._view = CADGraphicsViewWithOverlay()
|
||||
self._cad = CADWidget(self._view)
|
||||
|
||||
def load_file(self, path: str, layout: str = "Model"):
|
||||
try:
|
||||
if os.path.splitext(path)[1].lower() == ".dwg":
|
||||
doc = odafc.readfile(path)
|
||||
auditor = doc.audit()
|
||||
else:
|
||||
try:
|
||||
doc = ezdxf.readfile(path)
|
||||
except ezdxf.DXFError:
|
||||
doc, auditor = recover.readfile(path)
|
||||
else:
|
||||
auditor = doc.audit()
|
||||
self.set_document(doc, auditor, layout=layout)
|
||||
except IOError as e:
|
||||
qw.QMessageBox.critical(self, "Loading Error", str(e))
|
||||
except DXFStructureError as e:
|
||||
qw.QMessageBox.critical(
|
||||
self,
|
||||
"DXF Structure Error",
|
||||
f'Invalid DXF file "{path}": {str(e)}',
|
||||
)
|
||||
|
||||
def _select_doc(self):
|
||||
path, _ = qw.QFileDialog.getOpenFileName(
|
||||
self,
|
||||
caption="Select CAD Document",
|
||||
filter="CAD Documents (*.dxf *.DXF *.dwg *.DWG)",
|
||||
)
|
||||
if path:
|
||||
self.load_file(path)
|
||||
|
||||
def set_document(
|
||||
self,
|
||||
document: Drawing,
|
||||
auditor: Auditor,
|
||||
*,
|
||||
layout: str = "Model",
|
||||
draw: bool = True,
|
||||
):
|
||||
error_count = len(auditor.errors)
|
||||
if error_count > 0:
|
||||
ret = qw.QMessageBox.question(
|
||||
self,
|
||||
"Found DXF Errors",
|
||||
f'Found {error_count} errors in file "{document.filename}"\n'
|
||||
f"Load file anyway? ",
|
||||
)
|
||||
if ret == qw.QMessageBox.No:
|
||||
auditor.print_error_report(auditor.errors)
|
||||
return
|
||||
|
||||
if document.filename:
|
||||
try:
|
||||
self._watch_mtime = os.stat(document.filename).st_mtime
|
||||
except OSError:
|
||||
self._watch_mtime = None
|
||||
else:
|
||||
self._watch_mtime = None
|
||||
self._cad.set_document(document, layout=layout, draw=draw)
|
||||
self._doc = document
|
||||
self._populate_layouts()
|
||||
self._populate_layer_list()
|
||||
self.setWindowTitle("CAD Viewer - " + str(document.filename))
|
||||
|
||||
def _populate_layer_list(self):
|
||||
self.layers.blockSignals(True)
|
||||
self.layers.clear()
|
||||
for layer in self._cad.render_context.layers.values():
|
||||
name = layer.layer
|
||||
item = qw.QListWidgetItem()
|
||||
self.layers.addItem(item)
|
||||
checkbox = qw.QCheckBox(name)
|
||||
checkbox.setCheckState(
|
||||
qc.Qt.Checked if layer.is_visible else qc.Qt.Unchecked
|
||||
)
|
||||
checkbox.stateChanged.connect(self._layers_updated)
|
||||
text_color = "#FFFFFF" if is_dark_color(layer.color, 0.4) else "#000000"
|
||||
checkbox.setStyleSheet(
|
||||
f"color: {text_color}; background-color: {layer.color}"
|
||||
)
|
||||
self.layers.setItemWidget(item, checkbox)
|
||||
self.layers.blockSignals(False)
|
||||
|
||||
def _populate_layouts(self):
|
||||
def draw_layout(name: str):
|
||||
def run():
|
||||
self.draw_layout(name, reset_view=True)
|
||||
|
||||
return run
|
||||
|
||||
self.select_layout_menu.clear()
|
||||
for layout_name in self._cad.doc.layout_names_in_taborder():
|
||||
action = QAction(layout_name, self)
|
||||
action.triggered.connect(draw_layout(layout_name))
|
||||
self.select_layout_menu.addAction(action)
|
||||
|
||||
def draw_layout(
|
||||
self,
|
||||
layout_name: str,
|
||||
reset_view: bool = True,
|
||||
):
|
||||
print(f"drawing {layout_name}")
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
self._cad.draw_layout(layout_name, reset_view=reset_view)
|
||||
duration = time.perf_counter() - start
|
||||
print(f"took {duration:.4f} seconds")
|
||||
except DXFStructureError as e:
|
||||
qw.QMessageBox.critical(
|
||||
self,
|
||||
"DXF Structure Error",
|
||||
f'Abort rendering of layout "{layout_name}": {str(e)}',
|
||||
)
|
||||
|
||||
def resizeEvent(self, event: qg.QResizeEvent) -> None:
|
||||
self._view.fit_to_scene()
|
||||
|
||||
def _layer_checkboxes(self) -> Iterable[tuple[int, qw.QCheckBox]]:
|
||||
for i in range(self.layers.count()):
|
||||
item = self.layers.itemWidget(self.layers.item(i))
|
||||
yield i, item # type: ignore
|
||||
|
||||
@Slot(int) # type: ignore
|
||||
def _layers_updated(self, item_state: qc.Qt.CheckState):
|
||||
shift_held = qw.QApplication.keyboardModifiers() & qc.Qt.ShiftModifier
|
||||
if shift_held:
|
||||
for i, item in self._layer_checkboxes():
|
||||
item.blockSignals(True)
|
||||
item.setCheckState(item_state)
|
||||
item.blockSignals(False)
|
||||
|
||||
visible_layers = set()
|
||||
for i, layer in self._layer_checkboxes():
|
||||
if layer.checkState() == qc.Qt.Checked:
|
||||
visible_layers.add(layer.text())
|
||||
self._cad.set_visible_layers(visible_layers)
|
||||
|
||||
@Slot()
|
||||
def _toggle_sidebar(self):
|
||||
self.sidebar.setHidden(not self.sidebar.isHidden())
|
||||
|
||||
@Slot()
|
||||
def _toggle_selection_marker(self):
|
||||
self._view.toggle_selection_marker()
|
||||
|
||||
@Slot()
|
||||
def _reload(self):
|
||||
if self._cad.doc is not None and self._cad.doc.filename:
|
||||
keep_view = self.keep_view_action.isChecked()
|
||||
view = self._view.save_view() if keep_view else None
|
||||
self.load_file(self._cad.doc.filename, layout=self._cad.current_layout)
|
||||
if keep_view:
|
||||
self._view.restore_view(view)
|
||||
|
||||
@Slot()
|
||||
def _toggle_watch(self):
|
||||
if self._watch_timer.isActive():
|
||||
self._watch_timer.stop()
|
||||
else:
|
||||
self._watch_timer.start()
|
||||
|
||||
@Slot()
|
||||
def _check_watch(self):
|
||||
if self._watch_mtime is None or self._cad.doc is None:
|
||||
return
|
||||
filename = self._cad.doc.filename
|
||||
if filename:
|
||||
try:
|
||||
mtime = os.stat(filename).st_mtime
|
||||
except OSError:
|
||||
return
|
||||
if mtime != self._watch_mtime:
|
||||
self._reload()
|
||||
|
||||
@Slot(qc.QPointF)
|
||||
def _on_mouse_moved(self, mouse_pos: qc.QPointF):
|
||||
self.mouse_pos.setText(
|
||||
f"mouse position: {mouse_pos.x():.4f}, {mouse_pos.y():.4f}\n"
|
||||
)
|
||||
|
||||
@Slot(object, int)
|
||||
def _on_element_hovered(self, elements: list[qw.QGraphicsItem], index: int):
|
||||
if not elements:
|
||||
text = "No element selected"
|
||||
else:
|
||||
text = f"Selected: {index + 1} / {len(elements)} (click to cycle)\n"
|
||||
element = elements[index]
|
||||
dxf_entity: DXFGraphic | str | None = element.data(CorrespondingDXFEntity)
|
||||
if isinstance(dxf_entity, str):
|
||||
dxf_entity = self.load_dxf_entity(dxf_entity)
|
||||
if dxf_entity is None:
|
||||
text += "No data"
|
||||
else:
|
||||
text += (
|
||||
f"Selected Entity: {dxf_entity}\n"
|
||||
f"Layer: {dxf_entity.dxf.layer}\n\nDXF Attributes:\n"
|
||||
)
|
||||
text += _entity_attribs_string(dxf_entity)
|
||||
|
||||
dxf_parent_stack = element.data(CorrespondingDXFParentStack)
|
||||
if dxf_parent_stack:
|
||||
text += "\nParents:\n"
|
||||
for entity in reversed(dxf_parent_stack):
|
||||
text += f"- {entity}\n"
|
||||
text += _entity_attribs_string(entity, indent=" ")
|
||||
self.selected_info.setPlainText(text)
|
||||
|
||||
def load_dxf_entity(self, entity_handle: str) -> DXFGraphic | None:
|
||||
if self._doc is not None:
|
||||
return self._doc.entitydb.get(entity_handle)
|
||||
return None
|
||||
|
||||
|
||||
def _entity_attribs_string(dxf_entity: DXFGraphic, indent: str = "") -> str:
|
||||
text = ""
|
||||
for key, value in dxf_entity.dxf.all_existing_dxf_attribs().items():
|
||||
text += f"{indent}- {key}: {value}\n"
|
||||
return text
|
||||
@@ -0,0 +1,447 @@
|
||||
# Copyright (c) 2023-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Iterable,
|
||||
Iterator,
|
||||
Sequence,
|
||||
Callable,
|
||||
Optional,
|
||||
NamedTuple,
|
||||
)
|
||||
from typing_extensions import Self, TypeAlias
|
||||
import copy
|
||||
import abc
|
||||
|
||||
from ezdxf.math import BoundingBox2d, Matrix44, Vec2, UVec
|
||||
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, EmptyShapeError
|
||||
from ezdxf.tools import take2
|
||||
from ezdxf.tools.clipping_portal import ClippingRect
|
||||
|
||||
from .backend import BackendInterface, ImageData
|
||||
from .config import Configuration
|
||||
from .properties import BackendProperties
|
||||
from .type_hints import Color
|
||||
|
||||
|
||||
class DataRecord(abc.ABC):
|
||||
def __init__(self) -> None:
|
||||
self.property_hash: int = 0
|
||||
self.handle: str = ""
|
||||
|
||||
@abc.abstractmethod
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
...
|
||||
|
||||
|
||||
class PointsRecord(DataRecord):
|
||||
# n=1 point; n=2 line; n>2 filled polygon
|
||||
def __init__(self, points: NumpyPoints2d) -> None:
|
||||
super().__init__()
|
||||
self.points: NumpyPoints2d = points
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
try:
|
||||
return self.points.bbox()
|
||||
except EmptyShapeError:
|
||||
pass
|
||||
return BoundingBox2d()
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
self.points.transform_inplace(m)
|
||||
|
||||
|
||||
class SolidLinesRecord(DataRecord):
|
||||
def __init__(self, lines: NumpyPoints2d) -> None:
|
||||
super().__init__()
|
||||
self.lines: NumpyPoints2d = lines
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
try:
|
||||
return self.lines.bbox()
|
||||
except EmptyShapeError:
|
||||
pass
|
||||
return BoundingBox2d()
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
self.lines.transform_inplace(m)
|
||||
|
||||
|
||||
class PathRecord(DataRecord):
|
||||
def __init__(self, path: NumpyPath2d) -> None:
|
||||
super().__init__()
|
||||
self.path: NumpyPath2d = path
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
try:
|
||||
return self.path.bbox()
|
||||
except EmptyShapeError:
|
||||
pass
|
||||
return BoundingBox2d()
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
self.path.transform_inplace(m)
|
||||
|
||||
|
||||
class FilledPathsRecord(DataRecord):
|
||||
def __init__(self, paths: Sequence[NumpyPath2d]) -> None:
|
||||
super().__init__()
|
||||
self.paths: Sequence[NumpyPath2d] = paths
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
bbox = BoundingBox2d()
|
||||
for path in self.paths:
|
||||
if len(path):
|
||||
bbox.extend(path.extents())
|
||||
return bbox
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
for path in self.paths:
|
||||
path.transform_inplace(m)
|
||||
|
||||
|
||||
class ImageRecord(DataRecord):
|
||||
def __init__(self, boundary: NumpyPoints2d, image_data: ImageData) -> None:
|
||||
super().__init__()
|
||||
self.boundary: NumpyPoints2d = boundary
|
||||
self.image_data: ImageData = image_data
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
try:
|
||||
return self.boundary.bbox()
|
||||
except EmptyShapeError:
|
||||
pass
|
||||
return BoundingBox2d()
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
self.boundary.transform_inplace(m)
|
||||
self.image_data.transform @= m
|
||||
|
||||
|
||||
class Recorder(BackendInterface):
|
||||
"""Records the output of the Frontend class."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.config = Configuration()
|
||||
self.background: Color = "#000000"
|
||||
self.records: list[DataRecord] = []
|
||||
self.properties: dict[int, BackendProperties] = dict()
|
||||
|
||||
def player(self) -> Player:
|
||||
"""Returns a :class:`Player` instance with the original recordings! Make a copy
|
||||
of this player to protect the original recordings from being modified::
|
||||
|
||||
safe_player = recorder.player().copy()
|
||||
|
||||
"""
|
||||
player = Player()
|
||||
player.config = self.config
|
||||
player.background = self.background
|
||||
player.records = self.records
|
||||
player.properties = self.properties
|
||||
player.has_shared_recordings = True
|
||||
return player
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
self.config = config
|
||||
|
||||
def set_background(self, color: Color) -> None:
|
||||
self.background = color
|
||||
|
||||
def store(self, record: DataRecord, properties: BackendProperties) -> None:
|
||||
# exclude top-level entity handle to reduce the variance:
|
||||
# color, lineweight, layer, pen
|
||||
prop_hash = hash(properties[:4])
|
||||
record.property_hash = prop_hash
|
||||
record.handle = properties.handle
|
||||
self.records.append(record)
|
||||
self.properties[prop_hash] = properties
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
self.store(PointsRecord(NumpyPoints2d((pos,))), properties)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
self.store(PointsRecord(NumpyPoints2d((start, end))), properties)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
def flatten() -> Iterator[Vec2]:
|
||||
for s, e in lines:
|
||||
yield s
|
||||
yield e
|
||||
|
||||
self.store(SolidLinesRecord(NumpyPoints2d(flatten())), properties)
|
||||
|
||||
def draw_path(self, path: NumpyPath2d, properties: BackendProperties) -> None:
|
||||
assert isinstance(path, NumpyPath2d)
|
||||
self.store(PathRecord(path), properties)
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: NumpyPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
assert isinstance(points, NumpyPoints2d)
|
||||
self.store(PointsRecord(points), properties)
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[NumpyPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
paths = tuple(paths)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
|
||||
assert isinstance(paths[0], NumpyPath2d)
|
||||
self.store(FilledPathsRecord(paths), properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
# preserve the boundary in image_data in pixel coordinates
|
||||
boundary = copy.deepcopy(image_data.pixel_boundary_path)
|
||||
boundary.transform_inplace(image_data.transform)
|
||||
self.store(ImageRecord(boundary, image_data), properties)
|
||||
|
||||
def enter_entity(self, entity, properties) -> None:
|
||||
pass
|
||||
|
||||
def exit_entity(self, entity) -> None:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def finalize(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class Override(NamedTuple):
|
||||
"""Represents the override state for a data record.
|
||||
|
||||
Attributes:
|
||||
properties: original or modified :class:`BackendProperties`
|
||||
is_visible: override visibility e.g. switch layers on/off
|
||||
|
||||
"""
|
||||
|
||||
properties: BackendProperties
|
||||
is_visible: bool = True
|
||||
|
||||
|
||||
OverrideFunc: TypeAlias = Callable[[BackendProperties], Override]
|
||||
|
||||
|
||||
class Player:
|
||||
"""Plays the recordings of the :class:`Recorder` backend on another backend."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.config = Configuration()
|
||||
self.background: Color = "#000000"
|
||||
self.records: list[DataRecord] = []
|
||||
self.properties: dict[int, BackendProperties] = dict()
|
||||
self._bbox = BoundingBox2d()
|
||||
self.has_shared_recordings: bool = False
|
||||
|
||||
def __copy__(self) -> Self:
|
||||
"""Returns a copy of the player with non-shared recordings."""
|
||||
player = self.__class__()
|
||||
# config is a frozen dataclass:
|
||||
player.config = self.config
|
||||
player.background = self.background
|
||||
# recordings are mutable: transform and crop inplace
|
||||
player.records = copy.deepcopy(self.records)
|
||||
# the properties dict may grow, but entries will never be removed:
|
||||
player.properties = self.properties
|
||||
player.has_shared_recordings = False
|
||||
return player
|
||||
|
||||
copy = __copy__
|
||||
|
||||
def recordings(self) -> Iterator[tuple[DataRecord, BackendProperties]]:
|
||||
"""Yields all recordings as `(DataRecord, BackendProperties)` tuples."""
|
||||
props = self.properties
|
||||
for record in self.records:
|
||||
properties = BackendProperties(
|
||||
*props[record.property_hash][:4], record.handle
|
||||
)
|
||||
yield record, properties
|
||||
|
||||
def replay(
|
||||
self, backend: BackendInterface, override: Optional[OverrideFunc] = None
|
||||
) -> None:
|
||||
"""Replay the recording on another backend that implements the
|
||||
:class:`BackendInterface`. The optional `override` function can be used to
|
||||
override the properties and state of data records, it gets the :class:`BackendProperties`
|
||||
as input and must return an :class:`Override` instance.
|
||||
"""
|
||||
|
||||
backend.configure(self.config)
|
||||
backend.set_background(self.background)
|
||||
for record, properties in self.recordings():
|
||||
if override:
|
||||
state = override(properties)
|
||||
if not state.is_visible:
|
||||
continue
|
||||
properties = state.properties
|
||||
if isinstance(record, PointsRecord):
|
||||
count = len(record.points)
|
||||
if count == 0:
|
||||
continue
|
||||
if count > 2:
|
||||
backend.draw_filled_polygon(record.points, properties)
|
||||
continue
|
||||
vertices = record.points.vertices()
|
||||
if len(vertices) == 1:
|
||||
backend.draw_point(vertices[0], properties)
|
||||
else:
|
||||
backend.draw_line(vertices[0], vertices[1], properties)
|
||||
elif isinstance(record, SolidLinesRecord):
|
||||
backend.draw_solid_lines(take2(record.lines.vertices()), properties)
|
||||
elif isinstance(record, PathRecord):
|
||||
backend.draw_path(record.path, properties)
|
||||
elif isinstance(record, FilledPathsRecord):
|
||||
backend.draw_filled_paths(record.paths, properties)
|
||||
elif isinstance(record, ImageRecord):
|
||||
backend.draw_image(record.image_data, properties)
|
||||
backend.finalize()
|
||||
|
||||
def transform(self, m: Matrix44) -> None:
|
||||
"""Transforms the recordings inplace by a transformation matrix `m` of type
|
||||
:class:`~ezdxf.math.Matrix44`.
|
||||
"""
|
||||
for record in self.records:
|
||||
record.transform_inplace(m)
|
||||
|
||||
if self._bbox.has_data:
|
||||
# works for 90-, 180- and 270-degree rotation
|
||||
self._bbox = BoundingBox2d(m.fast_2d_transform(self._bbox.rect_vertices()))
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
"""Returns the bounding box of all records as :class:`~ezdxf.math.BoundingBox2d`."""
|
||||
if not self._bbox.has_data:
|
||||
self.update_bbox()
|
||||
return self._bbox
|
||||
|
||||
def update_bbox(self) -> None:
|
||||
bbox = BoundingBox2d()
|
||||
for record in self.records:
|
||||
bbox.extend(record.bbox())
|
||||
self._bbox = bbox
|
||||
|
||||
def crop_rect(self, p1: UVec, p2: UVec, distance: float) -> None:
|
||||
"""Crop recorded shapes inplace by a rectangle defined by two points.
|
||||
|
||||
The argument `distance` defines the approximation precision for paths which have
|
||||
to be approximated as polylines for cropping but only paths which are really get
|
||||
cropped are approximated, paths that are fully inside the crop box will not be
|
||||
approximated.
|
||||
|
||||
Args:
|
||||
p1: first corner of the clipping rectangle
|
||||
p2: second corner of the clipping rectangle
|
||||
distance: maximum distance from the center of the curve to the
|
||||
center of the line segment between two approximation points to
|
||||
determine if a segment should be subdivided.
|
||||
|
||||
"""
|
||||
crop_rect = BoundingBox2d([Vec2(p1), Vec2(p2)])
|
||||
self.records = crop_records_rect(self.records, crop_rect, distance)
|
||||
self._bbox = BoundingBox2d() # determine new bounding box on demand
|
||||
|
||||
|
||||
def crop_records_rect(
|
||||
records: list[DataRecord], crop_rect: BoundingBox2d, distance: float
|
||||
) -> list[DataRecord]:
|
||||
"""Crop recorded shapes inplace by a rectangle."""
|
||||
|
||||
def sort_paths(np_paths: Sequence[NumpyPath2d]):
|
||||
_inside: list[NumpyPath2d] = []
|
||||
_crop: list[NumpyPath2d] = []
|
||||
|
||||
for np_path in np_paths:
|
||||
bbox = BoundingBox2d(np_path.extents())
|
||||
if not crop_rect.has_intersection(bbox):
|
||||
# path is complete outside the cropping rectangle
|
||||
pass
|
||||
elif crop_rect.inside(bbox.extmin) and crop_rect.inside(bbox.extmax):
|
||||
# path is complete inside the cropping rectangle
|
||||
_inside.append(np_path)
|
||||
else:
|
||||
_crop.append(np_path)
|
||||
|
||||
return _crop, _inside
|
||||
|
||||
def crop_paths(
|
||||
np_paths: Sequence[NumpyPath2d],
|
||||
) -> list[NumpyPath2d]:
|
||||
return list(clipper.clip_filled_paths(np_paths, distance))
|
||||
|
||||
# an undefined crop box crops nothing:
|
||||
if not crop_rect.has_data:
|
||||
return records
|
||||
cropped_records: list[DataRecord] = []
|
||||
size = crop_rect.size
|
||||
# a crop box size of zero in any dimension crops everything:
|
||||
if size.x < 1e-12 or size.y < 1e-12:
|
||||
return cropped_records
|
||||
|
||||
clipper = ClippingRect(crop_rect.rect_vertices())
|
||||
for record in records:
|
||||
record_box = record.bbox()
|
||||
if not crop_rect.has_intersection(record_box):
|
||||
# record is complete outside the cropping rectangle
|
||||
continue
|
||||
if crop_rect.inside(record_box.extmin) and crop_rect.inside(record_box.extmax):
|
||||
# record is complete inside the cropping rectangle
|
||||
cropped_records.append(record)
|
||||
continue
|
||||
|
||||
if isinstance(record, FilledPathsRecord):
|
||||
paths_to_crop, inside = sort_paths(record.paths)
|
||||
cropped_paths = crop_paths(paths_to_crop) + inside
|
||||
if cropped_paths:
|
||||
record.paths = tuple(cropped_paths)
|
||||
cropped_records.append(record)
|
||||
elif isinstance(record, PathRecord):
|
||||
# could be split into multiple parts
|
||||
for p in clipper.clip_paths([record.path], distance):
|
||||
path_record = PathRecord(p)
|
||||
path_record.property_hash = record.property_hash
|
||||
path_record.handle = record.handle
|
||||
cropped_records.append(path_record)
|
||||
elif isinstance(record, PointsRecord):
|
||||
count = len(record.points)
|
||||
if count == 1:
|
||||
# record is inside the clipping shape!
|
||||
cropped_records.append(record)
|
||||
elif count == 2:
|
||||
s, e = record.points.vertices()
|
||||
for segment in clipper.clip_line(s, e):
|
||||
if not segment:
|
||||
continue
|
||||
_record = copy.copy(record) # shallow copy
|
||||
_record.points = NumpyPoints2d(segment)
|
||||
cropped_records.append(_record)
|
||||
else:
|
||||
for polygon in clipper.clip_polygon(record.points):
|
||||
if not polygon:
|
||||
continue
|
||||
_record = copy.copy(record) # shallow copy!
|
||||
_record.points = polygon
|
||||
cropped_records.append(_record)
|
||||
elif isinstance(record, SolidLinesRecord):
|
||||
points: list[Vec2] = []
|
||||
for s, e in take2(record.lines.vertices()):
|
||||
for segment in clipper.clip_line(s, e):
|
||||
points.extend(segment)
|
||||
record.lines = NumpyPoints2d(points)
|
||||
cropped_records.append(record)
|
||||
elif isinstance(record, ImageRecord):
|
||||
pass
|
||||
# TODO: Image cropping not supported
|
||||
# Crop image boundary and apply transparency to cropped
|
||||
# parts of the image? -> Image boundary is now a polygon!
|
||||
else:
|
||||
raise ValueError("invalid record type")
|
||||
return cropped_records
|
||||
@@ -0,0 +1,422 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence, no_type_check
|
||||
|
||||
import copy
|
||||
from xml.etree import ElementTree as ET
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
|
||||
from ezdxf.path import Command
|
||||
|
||||
|
||||
from .type_hints import Color
|
||||
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
|
||||
from .config import Configuration, LineweightPolicy
|
||||
from .properties import BackendProperties
|
||||
from . import layout, recorder
|
||||
|
||||
__all__ = ["SVGBackend"]
|
||||
|
||||
|
||||
class SVGBackend(recorder.Recorder):
|
||||
"""This is a native SVG rendering backend and does not require any external packages
|
||||
to render SVG images other than the core dependencies. This backend support content
|
||||
cropping at page margins.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._init_flip_y = True
|
||||
|
||||
def get_xml_root_element(
|
||||
self,
|
||||
page: layout.Page,
|
||||
*,
|
||||
settings: layout.Settings = layout.Settings(),
|
||||
render_box: BoundingBox2d | None = None,
|
||||
) -> ET.Element:
|
||||
top_origin = True
|
||||
settings = copy.copy(settings)
|
||||
# DXF coordinates are mapped to integer viewBox coordinates in the first
|
||||
# quadrant, producing compact SVG files. The larger the coordinate range, the
|
||||
# more precise and the lager the files.
|
||||
settings.output_coordinate_space = 1_000_000
|
||||
|
||||
# This player changes the original recordings!
|
||||
player = self.player()
|
||||
if render_box is None:
|
||||
render_box = player.bbox()
|
||||
|
||||
# the page origin (0, 0) is in the top-left corner.
|
||||
output_layout = layout.Layout(render_box, flip_y=self._init_flip_y)
|
||||
page = output_layout.get_final_page(page, settings)
|
||||
if page.width == 0 or page.height == 0:
|
||||
return ET.Element("svg") # empty page
|
||||
|
||||
m = output_layout.get_placement_matrix(
|
||||
page, settings=settings, top_origin=top_origin
|
||||
)
|
||||
# transform content to the output coordinates space:
|
||||
player.transform(m)
|
||||
if settings.crop_at_margins:
|
||||
p1, p2 = page.get_margin_rect(top_origin=top_origin) # in mm
|
||||
# scale factor to map page coordinates to output space coordinates:
|
||||
output_scale = settings.page_output_scale_factor(page)
|
||||
max_sagitta = 0.1 * output_scale # curve approximation 0.1 mm
|
||||
# crop content inplace by the margin rect:
|
||||
player.crop_rect(p1 * output_scale, p2 * output_scale, max_sagitta)
|
||||
|
||||
self._init_flip_y = False
|
||||
backend = self.make_backend(page, settings)
|
||||
player.replay(backend)
|
||||
return backend.get_xml_root_element()
|
||||
|
||||
def get_string(
|
||||
self,
|
||||
page: layout.Page,
|
||||
*,
|
||||
settings: layout.Settings = layout.Settings(),
|
||||
render_box: BoundingBox2d | None = None,
|
||||
xml_declaration=True,
|
||||
) -> str:
|
||||
"""Returns the XML data as unicode string.
|
||||
|
||||
Args:
|
||||
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
|
||||
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
|
||||
render_box: set explicit region to render, default is content bounding box
|
||||
xml_declaration: inserts the "<?xml version='1.0' encoding='utf-8'?>" string
|
||||
in front of the <svg> element
|
||||
|
||||
"""
|
||||
xml = self.get_xml_root_element(page, settings=settings, render_box=render_box)
|
||||
return ET.tostring(xml, encoding="unicode", xml_declaration=xml_declaration)
|
||||
|
||||
@staticmethod
|
||||
def make_backend(page: layout.Page, settings: layout.Settings) -> SVGRenderBackend:
|
||||
"""Override this method to use a customized render backend."""
|
||||
return SVGRenderBackend(page, settings)
|
||||
|
||||
|
||||
def make_view_box(page: layout.Page, output_coordinate_space: float) -> tuple[int, int]:
|
||||
size = round(output_coordinate_space)
|
||||
if page.width > page.height:
|
||||
return size, round(size * (page.height / page.width))
|
||||
return round(size * (page.width / page.height)), size
|
||||
|
||||
|
||||
def scale_page_to_view_box(page: layout.Page, output_coordinate_space: float) -> float:
|
||||
# The viewBox coordinates are integer values in the range of [0, output_coordinate_space]
|
||||
return min(
|
||||
output_coordinate_space / page.width,
|
||||
output_coordinate_space / page.height,
|
||||
)
|
||||
|
||||
|
||||
class Styles:
|
||||
def __init__(self, xml: ET.Element) -> None:
|
||||
self._xml = xml
|
||||
self._class_names: dict[int, str] = dict()
|
||||
self._counter = 1
|
||||
|
||||
def get_class(
|
||||
self,
|
||||
*,
|
||||
stroke: Color = "none",
|
||||
stroke_width: int | str = "none",
|
||||
stroke_opacity: float = 1.0,
|
||||
fill: Color = "none",
|
||||
fill_opacity: float = 1.0,
|
||||
) -> str:
|
||||
style = (
|
||||
f"{{stroke: {stroke}; "
|
||||
f"stroke-width: {stroke_width}; "
|
||||
f"stroke-opacity: {stroke_opacity:.3f}; "
|
||||
f"fill: {fill}; "
|
||||
f"fill-opacity: {fill_opacity:.3f};}}"
|
||||
)
|
||||
key = hash(style)
|
||||
try:
|
||||
return self._class_names[key]
|
||||
except KeyError:
|
||||
pass
|
||||
name = f"C{self._counter:X}"
|
||||
self._counter += 1
|
||||
self._add_class(name, style)
|
||||
self._class_names[key] = name
|
||||
return name
|
||||
|
||||
def _add_class(self, name, style_str: str) -> None:
|
||||
style = ET.Element("style")
|
||||
style.text = f".{name} {style_str}"
|
||||
self._xml.append(style)
|
||||
|
||||
|
||||
CMD_M_ABS = "M {0.x:.0f} {0.y:.0f}"
|
||||
CMD_M_REL = "m {0.x:.0f} {0.y:.0f}"
|
||||
CMD_L_ABS = "L {0.x:.0f} {0.y:.0f}"
|
||||
CMD_L_REL = "l {0.x:.0f} {0.y:.0f}"
|
||||
CMD_C3_ABS = "Q {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f}"
|
||||
CMD_C3_REL = "q {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f}"
|
||||
CMD_C4_ABS = "C {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f} {2.x:.0f} {2.y:.0f}"
|
||||
CMD_C4_REL = "c {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f} {2.x:.0f} {2.y:.0f}"
|
||||
CMD_CONT = "{0.x:.0f} {0.y:.0f}"
|
||||
|
||||
|
||||
class SVGRenderBackend(BackendInterface):
|
||||
"""Creates the SVG output.
|
||||
|
||||
This backend requires some preliminary work, record the frontend output via the
|
||||
Recorder backend to accomplish the following requirements:
|
||||
|
||||
- Scale the content in y-axis by -1 to invert the y-axis (SVG).
|
||||
- Move content in the first quadrant of the coordinate system.
|
||||
- The viewBox is defined by the lower left corner in the origin (0, 0) and
|
||||
the upper right corner at (view_box_width, view_box_height)
|
||||
- The output coordinates are integer values, scale the content appropriately.
|
||||
- Replay the recorded output on this backend.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, page: layout.Page, settings: layout.Settings) -> None:
|
||||
self.settings = settings
|
||||
self._stroke_width_cache: dict[float, int] = dict()
|
||||
view_box_width, view_box_height = make_view_box(
|
||||
page, settings.output_coordinate_space
|
||||
)
|
||||
# StrokeWidthPolicy.absolute:
|
||||
# stroke-width in mm as resolved by the frontend
|
||||
self.stroke_width_scale: float = view_box_width / page.width_in_mm
|
||||
self.min_lineweight = 0.05 # in mm, set by configure()
|
||||
self.lineweight_scaling = 1.0 # set by configure()
|
||||
self.lineweight_policy = LineweightPolicy.ABSOLUTE # set by configure()
|
||||
# fixed lineweight for all strokes in ABSOLUTE mode:
|
||||
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
|
||||
# set Configuration.lineweight_scaling to 0
|
||||
|
||||
# LineweightPolicy.RELATIVE:
|
||||
# max_stroke_width is determined as a certain percentage of settings.output_coordinate_space
|
||||
self.max_stroke_width: int = int(
|
||||
settings.output_coordinate_space * settings.max_stroke_width
|
||||
)
|
||||
# min_stroke_width is determined as a certain percentage of max_stroke_width
|
||||
self.min_stroke_width: int = int(
|
||||
self.max_stroke_width * settings.min_stroke_width
|
||||
)
|
||||
# LineweightPolicy.RELATIVE_FIXED:
|
||||
# all strokes have a fixed stroke-width as a certain percentage of max_stroke_width
|
||||
self.fixed_stroke_width: int = int(
|
||||
self.max_stroke_width * settings.fixed_stroke_width
|
||||
)
|
||||
self.root = ET.Element(
|
||||
"svg",
|
||||
xmlns="http://www.w3.org/2000/svg",
|
||||
width=f"{page.width_in_mm:g}mm",
|
||||
height=f"{page.height_in_mm:g}mm",
|
||||
viewBox=f"0 0 {view_box_width} {view_box_height}",
|
||||
)
|
||||
self.styles = Styles(ET.SubElement(self.root, "defs"))
|
||||
self.background = ET.SubElement(
|
||||
self.root,
|
||||
"rect",
|
||||
fill="white",
|
||||
x="0",
|
||||
y="0",
|
||||
width=str(view_box_width),
|
||||
height=str(view_box_height),
|
||||
)
|
||||
self.entities = ET.SubElement(self.root, "g")
|
||||
self.entities.set("stroke-linecap", "round")
|
||||
self.entities.set("stroke-linejoin", "round")
|
||||
self.entities.set("fill-rule", "evenodd")
|
||||
|
||||
def get_xml_root_element(self) -> ET.Element:
|
||||
return self.root
|
||||
|
||||
def add_strokes(self, d: str, properties: BackendProperties):
|
||||
if not d:
|
||||
return
|
||||
element = ET.SubElement(self.entities, "path", d=d)
|
||||
stroke_width = self.resolve_stroke_width(properties.lineweight)
|
||||
stroke_color, stroke_opacity = self.resolve_color(properties.color)
|
||||
cls = self.styles.get_class(
|
||||
stroke=stroke_color,
|
||||
stroke_width=stroke_width,
|
||||
stroke_opacity=stroke_opacity,
|
||||
)
|
||||
element.set("class", cls)
|
||||
|
||||
def add_filling(self, d: str, properties: BackendProperties):
|
||||
if not d:
|
||||
return
|
||||
element = ET.SubElement(self.entities, "path", d=d)
|
||||
fill_color, fill_opacity = self.resolve_color(properties.color)
|
||||
cls = self.styles.get_class(fill=fill_color, fill_opacity=fill_opacity)
|
||||
element.set("class", cls)
|
||||
|
||||
def resolve_color(self, color: Color) -> tuple[Color, float]:
|
||||
return color[:7], alpha_to_opacity(color[7:9])
|
||||
|
||||
def resolve_stroke_width(self, width: float) -> int:
|
||||
try:
|
||||
return self._stroke_width_cache[width]
|
||||
except KeyError:
|
||||
pass
|
||||
stroke_width = self.fixed_stroke_width
|
||||
policy = self.lineweight_policy
|
||||
if policy == LineweightPolicy.ABSOLUTE:
|
||||
if self.lineweight_scaling:
|
||||
width = max(self.min_lineweight, width) * self.lineweight_scaling
|
||||
else:
|
||||
width = self.min_lineweight
|
||||
stroke_width = round(width * self.stroke_width_scale)
|
||||
elif policy == LineweightPolicy.RELATIVE:
|
||||
stroke_width = map_lineweight_to_stroke_width(
|
||||
width, self.min_stroke_width, self.max_stroke_width
|
||||
)
|
||||
self._stroke_width_cache[width] = stroke_width
|
||||
return stroke_width
|
||||
|
||||
def set_background(self, color: Color) -> None:
|
||||
color_str = color[:7]
|
||||
opacity = alpha_to_opacity(color[7:9])
|
||||
self.background.set("fill", color_str)
|
||||
self.background.set("fill-opacity", str(opacity))
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
self.add_strokes(self.make_polyline_str([pos, pos]), properties)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
self.add_strokes(self.make_polyline_str([start, end]), properties)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
lines = list(lines)
|
||||
if len(lines) == 0:
|
||||
return
|
||||
self.add_strokes(self.make_multi_line_str(lines), properties)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
self.add_strokes(self.make_path_str(path), properties)
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
d = []
|
||||
for path in paths:
|
||||
if len(path):
|
||||
d.append(self.make_path_str(path, close=True))
|
||||
self.add_filling(" ".join(d), properties)
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
self.add_filling(
|
||||
self.make_polyline_str(points.vertices(), close=True), properties
|
||||
)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
pass # TODO: not implemented
|
||||
|
||||
@staticmethod
|
||||
def make_polyline_str(points: Sequence[Vec2], close=False) -> str:
|
||||
if len(points) < 2:
|
||||
return ""
|
||||
current = points[0]
|
||||
# first move is absolute, consecutive lines are relative:
|
||||
d: list[str] = [CMD_M_ABS.format(current), "l"]
|
||||
for point in points[1:]:
|
||||
relative = point - current
|
||||
current = point
|
||||
d.append(CMD_CONT.format(relative))
|
||||
if close:
|
||||
d.append("Z")
|
||||
return " ".join(d)
|
||||
|
||||
@staticmethod
|
||||
def make_multi_line_str(lines: Sequence[tuple[Vec2, Vec2]]) -> str:
|
||||
assert len(lines) > 0
|
||||
start, end = lines[0]
|
||||
d: list[str] = [CMD_M_ABS.format(start), CMD_L_REL.format(end - start)]
|
||||
current = end
|
||||
for start, end in lines[1:]:
|
||||
d.append(CMD_M_REL.format(start - current))
|
||||
current = start
|
||||
d.append(CMD_L_REL.format(end - current))
|
||||
current = end
|
||||
return " ".join(d)
|
||||
|
||||
@staticmethod
|
||||
@no_type_check
|
||||
def make_path_str(path: BkPath2d, close=False) -> str:
|
||||
d: list[str] = [CMD_M_ABS.format(path.start)]
|
||||
if len(path) == 0:
|
||||
return ""
|
||||
|
||||
current = path.start
|
||||
for cmd in path.commands():
|
||||
end = cmd.end
|
||||
if cmd.type == Command.MOVE_TO:
|
||||
d.append(CMD_M_REL.format(end - current))
|
||||
elif cmd.type == Command.LINE_TO:
|
||||
d.append(CMD_L_REL.format(end - current))
|
||||
elif cmd.type == Command.CURVE3_TO:
|
||||
d.append(CMD_C3_REL.format(cmd.ctrl - current, end - current))
|
||||
elif cmd.type == Command.CURVE4_TO:
|
||||
d.append(
|
||||
CMD_C4_REL.format(
|
||||
cmd.ctrl1 - current, cmd.ctrl2 - current, end - current
|
||||
)
|
||||
)
|
||||
current = end
|
||||
if close:
|
||||
d.append("Z")
|
||||
|
||||
return " ".join(d)
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
self.lineweight_policy = config.lineweight_policy
|
||||
if config.min_lineweight:
|
||||
# config.min_lineweight in 1/300 inch!
|
||||
min_lineweight_mm = config.min_lineweight * 25.4 / 300
|
||||
self.min_lineweight = max(0.05, min_lineweight_mm)
|
||||
self.lineweight_scaling = config.lineweight_scaling
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def finalize(self) -> None:
|
||||
pass
|
||||
|
||||
def enter_entity(self, entity, properties) -> None:
|
||||
pass
|
||||
|
||||
def exit_entity(self, entity) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def alpha_to_opacity(alpha: str) -> float:
|
||||
# stroke-opacity: 0.0 = transparent; 1.0 = opaque
|
||||
# alpha: "00" = transparent; "ff" = opaque
|
||||
if len(alpha):
|
||||
try:
|
||||
return int(alpha, 16) / 255
|
||||
except ValueError:
|
||||
pass
|
||||
return 1.0
|
||||
|
||||
|
||||
def map_lineweight_to_stroke_width(
|
||||
lineweight: float,
|
||||
min_stroke_width: int,
|
||||
max_stroke_width: int,
|
||||
min_lineweight=0.05, # defined by DXF
|
||||
max_lineweight=2.11, # defined by DXF
|
||||
) -> int:
|
||||
"""Map the DXF lineweight in mm to stroke-width in viewBox coordinates."""
|
||||
lineweight = max(min(lineweight, max_lineweight), min_lineweight) - min_lineweight
|
||||
factor = (max_stroke_width - min_stroke_width) / (max_lineweight - min_lineweight)
|
||||
return min_stroke_width + round(lineweight * factor)
|
||||
@@ -0,0 +1,351 @@
|
||||
# Copyright (c) 2020-2023, Matthew Broadway
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Union, Tuple, Iterable, Optional, Callable
|
||||
from typing_extensions import TypeAlias
|
||||
import enum
|
||||
from math import radians
|
||||
|
||||
import ezdxf.lldxf.const as DXFConstants
|
||||
from ezdxf.enums import (
|
||||
TextEntityAlignment,
|
||||
MAP_TEXT_ENUM_TO_ALIGN_FLAGS,
|
||||
MTextEntityAlignment,
|
||||
)
|
||||
from ezdxf.entities import MText, Text, Attrib, AttDef
|
||||
from ezdxf.math import Matrix44, Vec3, sign
|
||||
from ezdxf.fonts import fonts
|
||||
from ezdxf.fonts.font_measurements import FontMeasurements
|
||||
from ezdxf.tools.text import plain_text, text_wrap
|
||||
from .text_renderer import TextRenderer
|
||||
|
||||
"""
|
||||
Search google for 'typography' or 'font anatomy' for explanations of terms like
|
||||
'baseline' and 'x-height'
|
||||
|
||||
A Visual Guide to the Anatomy of Typography: https://visme.co/blog/type-anatomy/
|
||||
Anatomy of a Character: https://www.fonts.com/content/learning/fontology/level-1/type-anatomy/anatomy
|
||||
"""
|
||||
|
||||
|
||||
@enum.unique
|
||||
class HAlignment(enum.Enum):
|
||||
LEFT = 0
|
||||
CENTER = 1
|
||||
RIGHT = 2
|
||||
|
||||
|
||||
@enum.unique
|
||||
class VAlignment(enum.Enum):
|
||||
TOP = 0 # the top of capital letters or letters with ascenders (like 'b')
|
||||
LOWER_CASE_CENTER = 1 # the midpoint between the baseline and the x-height
|
||||
BASELINE = 2 # the line which text rests on, characters with descenders (like 'p') are partially below this line
|
||||
BOTTOM = 3 # the lowest point on a character with a descender (like 'p')
|
||||
UPPER_CASE_CENTER = 4 # the midpoint between the baseline and the cap-height
|
||||
|
||||
|
||||
Alignment: TypeAlias = Tuple[HAlignment, VAlignment]
|
||||
AnyText: TypeAlias = Union[Text, MText, Attrib, AttDef]
|
||||
|
||||
# multiple of cap_height between the baseline of the previous line and the
|
||||
# baseline of the next line
|
||||
DEFAULT_LINE_SPACING = 5 / 3
|
||||
|
||||
DXF_TEXT_ALIGNMENT_TO_ALIGNMENT: dict[TextEntityAlignment, Alignment] = {
|
||||
TextEntityAlignment.LEFT: (HAlignment.LEFT, VAlignment.BASELINE),
|
||||
TextEntityAlignment.CENTER: (HAlignment.CENTER, VAlignment.BASELINE),
|
||||
TextEntityAlignment.RIGHT: (HAlignment.RIGHT, VAlignment.BASELINE),
|
||||
TextEntityAlignment.ALIGNED: (HAlignment.CENTER, VAlignment.BASELINE),
|
||||
TextEntityAlignment.MIDDLE: (
|
||||
HAlignment.CENTER,
|
||||
VAlignment.LOWER_CASE_CENTER,
|
||||
),
|
||||
TextEntityAlignment.FIT: (HAlignment.CENTER, VAlignment.BASELINE),
|
||||
TextEntityAlignment.BOTTOM_LEFT: (HAlignment.LEFT, VAlignment.BOTTOM),
|
||||
TextEntityAlignment.BOTTOM_CENTER: (HAlignment.CENTER, VAlignment.BOTTOM),
|
||||
TextEntityAlignment.BOTTOM_RIGHT: (HAlignment.RIGHT, VAlignment.BOTTOM),
|
||||
TextEntityAlignment.MIDDLE_LEFT: (
|
||||
HAlignment.LEFT,
|
||||
VAlignment.UPPER_CASE_CENTER,
|
||||
),
|
||||
TextEntityAlignment.MIDDLE_CENTER: (
|
||||
HAlignment.CENTER,
|
||||
VAlignment.UPPER_CASE_CENTER,
|
||||
),
|
||||
TextEntityAlignment.MIDDLE_RIGHT: (
|
||||
HAlignment.RIGHT,
|
||||
VAlignment.UPPER_CASE_CENTER,
|
||||
),
|
||||
TextEntityAlignment.TOP_LEFT: (HAlignment.LEFT, VAlignment.TOP),
|
||||
TextEntityAlignment.TOP_CENTER: (HAlignment.CENTER, VAlignment.TOP),
|
||||
TextEntityAlignment.TOP_RIGHT: (HAlignment.RIGHT, VAlignment.TOP),
|
||||
}
|
||||
assert DXF_TEXT_ALIGNMENT_TO_ALIGNMENT.keys() == MAP_TEXT_ENUM_TO_ALIGN_FLAGS.keys()
|
||||
|
||||
DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT: dict[int, Alignment] = {
|
||||
DXFConstants.MTEXT_TOP_LEFT: (HAlignment.LEFT, VAlignment.TOP),
|
||||
DXFConstants.MTEXT_TOP_CENTER: (HAlignment.CENTER, VAlignment.TOP),
|
||||
DXFConstants.MTEXT_TOP_RIGHT: (HAlignment.RIGHT, VAlignment.TOP),
|
||||
DXFConstants.MTEXT_MIDDLE_LEFT: (
|
||||
HAlignment.LEFT,
|
||||
VAlignment.LOWER_CASE_CENTER,
|
||||
),
|
||||
DXFConstants.MTEXT_MIDDLE_CENTER: (
|
||||
HAlignment.CENTER,
|
||||
VAlignment.LOWER_CASE_CENTER,
|
||||
),
|
||||
DXFConstants.MTEXT_MIDDLE_RIGHT: (
|
||||
HAlignment.RIGHT,
|
||||
VAlignment.LOWER_CASE_CENTER,
|
||||
),
|
||||
DXFConstants.MTEXT_BOTTOM_LEFT: (HAlignment.LEFT, VAlignment.BOTTOM),
|
||||
DXFConstants.MTEXT_BOTTOM_CENTER: (HAlignment.CENTER, VAlignment.BOTTOM),
|
||||
DXFConstants.MTEXT_BOTTOM_RIGHT: (HAlignment.RIGHT, VAlignment.BOTTOM),
|
||||
}
|
||||
assert len(DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT) == len(MTextEntityAlignment)
|
||||
|
||||
|
||||
def _calc_aligned_rotation(text: Text) -> float:
|
||||
p1: Vec3 = text.dxf.insert
|
||||
p2: Vec3 = text.dxf.align_point
|
||||
if not p1.isclose(p2):
|
||||
return (p2 - p1).angle
|
||||
else:
|
||||
return radians(text.dxf.rotation)
|
||||
|
||||
|
||||
def _get_rotation(text: AnyText) -> Matrix44:
|
||||
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
|
||||
if text.get_align_enum() in (
|
||||
TextEntityAlignment.FIT,
|
||||
TextEntityAlignment.ALIGNED,
|
||||
):
|
||||
rotation = _calc_aligned_rotation(text)
|
||||
else:
|
||||
rotation = radians(text.dxf.rotation)
|
||||
return Matrix44.axis_rotate(text.dxf.extrusion, rotation)
|
||||
elif isinstance(text, MText):
|
||||
return Matrix44.axis_rotate(Vec3(0, 0, 1), radians(text.get_rotation()))
|
||||
else:
|
||||
raise TypeError(type(text))
|
||||
|
||||
|
||||
def _get_alignment(text: AnyText) -> Alignment:
|
||||
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
|
||||
return DXF_TEXT_ALIGNMENT_TO_ALIGNMENT[text.get_align_enum()]
|
||||
elif isinstance(text, MText):
|
||||
return DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT[text.dxf.attachment_point]
|
||||
else:
|
||||
raise TypeError(type(text))
|
||||
|
||||
|
||||
def _get_cap_height(text: AnyText) -> float:
|
||||
if isinstance(text, (Text, Attrib, AttDef)):
|
||||
return text.dxf.height
|
||||
elif isinstance(text, MText):
|
||||
return text.dxf.char_height
|
||||
else:
|
||||
raise TypeError(type(text))
|
||||
|
||||
|
||||
def _get_line_spacing(text: AnyText, cap_height: float) -> float:
|
||||
if isinstance(text, (Attrib, AttDef, Text)):
|
||||
return 0.0
|
||||
elif isinstance(text, MText):
|
||||
return cap_height * DEFAULT_LINE_SPACING * text.dxf.line_spacing_factor
|
||||
else:
|
||||
raise TypeError(type(text))
|
||||
|
||||
|
||||
def _split_into_lines(
|
||||
entity: AnyText,
|
||||
box_width: Optional[float],
|
||||
get_text_width: Callable[[str], float],
|
||||
) -> list[str]:
|
||||
if isinstance(entity, AttDef):
|
||||
# ATTDEF outside of an Insert renders the tag rather than the value
|
||||
text = plain_text(entity.dxf.tag)
|
||||
else:
|
||||
text = entity.plain_text() # type: ignore
|
||||
if isinstance(entity, (Text, Attrib, AttDef)):
|
||||
assert "\n" not in text
|
||||
return [text]
|
||||
else:
|
||||
return text_wrap(text, box_width, get_text_width)
|
||||
|
||||
|
||||
def _get_text_width(text: AnyText) -> Optional[float]:
|
||||
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
|
||||
return None
|
||||
elif isinstance(text, MText):
|
||||
width = text.dxf.width
|
||||
return None if width == 0.0 else width
|
||||
else:
|
||||
raise TypeError(type(text))
|
||||
|
||||
|
||||
def _get_extra_transform(text: AnyText, line_width: float) -> Matrix44:
|
||||
extra_transform = Matrix44()
|
||||
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
|
||||
# 'width' is the width *scale factor* so 1.0 by default:
|
||||
scale_x = text.dxf.width
|
||||
scale_y = 1.0
|
||||
|
||||
# Calculate text stretching for FIT and ALIGNED:
|
||||
alignment = text.get_align_enum()
|
||||
line_width = abs(line_width)
|
||||
if (
|
||||
alignment in (TextEntityAlignment.FIT, TextEntityAlignment.ALIGNED)
|
||||
and line_width > 1e-9
|
||||
):
|
||||
defined_length = (text.dxf.align_point - text.dxf.insert).magnitude
|
||||
stretch_factor = defined_length / line_width
|
||||
scale_x = stretch_factor
|
||||
if alignment == TextEntityAlignment.ALIGNED:
|
||||
scale_y = stretch_factor
|
||||
|
||||
if text.dxf.text_generation_flag & DXFConstants.MIRROR_X:
|
||||
scale_x *= -1.0
|
||||
if text.dxf.text_generation_flag & DXFConstants.MIRROR_Y:
|
||||
scale_y *= -1.0
|
||||
|
||||
# Magnitude of extrusion does not have any effect.
|
||||
# An extrusion of (0, 0, 0) acts like (0, 0, 1)
|
||||
scale_x *= sign(text.dxf.extrusion.z)
|
||||
|
||||
if scale_x != 1.0 or scale_y != 1.0:
|
||||
extra_transform = Matrix44.scale(scale_x, scale_y)
|
||||
|
||||
elif isinstance(text, MText):
|
||||
# Not sure about the rationale behind this but it does match AutoCAD
|
||||
# behavior...
|
||||
scale_y = sign(text.dxf.extrusion.z)
|
||||
if scale_y != 1.0:
|
||||
extra_transform = Matrix44.scale(1.0, scale_y)
|
||||
|
||||
return extra_transform
|
||||
|
||||
|
||||
def _apply_alignment(
|
||||
alignment: Alignment,
|
||||
line_widths: list[float],
|
||||
line_spacing: float,
|
||||
box_width: Optional[float],
|
||||
font_measurements: FontMeasurements,
|
||||
) -> tuple[tuple[float, float], list[float], list[float]]:
|
||||
if not line_widths:
|
||||
return (0, 0), [], []
|
||||
|
||||
halign, valign = alignment
|
||||
line_ys = [
|
||||
-font_measurements.baseline - (font_measurements.cap_height + i * line_spacing)
|
||||
for i in range(len(line_widths))
|
||||
]
|
||||
|
||||
if box_width is None:
|
||||
box_width = max(line_widths)
|
||||
|
||||
last_baseline = line_ys[-1]
|
||||
|
||||
if halign == HAlignment.LEFT:
|
||||
anchor_x = 0.0
|
||||
line_xs = [0.0] * len(line_widths)
|
||||
elif halign == HAlignment.CENTER:
|
||||
anchor_x = box_width / 2
|
||||
line_xs = [anchor_x - w / 2 for w in line_widths]
|
||||
elif halign == HAlignment.RIGHT:
|
||||
anchor_x = box_width
|
||||
line_xs = [anchor_x - w for w in line_widths]
|
||||
else:
|
||||
raise ValueError(halign)
|
||||
|
||||
if valign == VAlignment.TOP:
|
||||
anchor_y = 0.0
|
||||
elif valign == VAlignment.LOWER_CASE_CENTER:
|
||||
first_line_lower_case_top = line_ys[0] + font_measurements.x_height
|
||||
anchor_y = (first_line_lower_case_top + last_baseline) / 2
|
||||
elif valign == VAlignment.UPPER_CASE_CENTER:
|
||||
first_line_upper_case_top = line_ys[0] + font_measurements.cap_height
|
||||
anchor_y = (first_line_upper_case_top + last_baseline) / 2
|
||||
elif valign == VAlignment.BASELINE:
|
||||
anchor_y = last_baseline
|
||||
elif valign == VAlignment.BOTTOM:
|
||||
anchor_y = last_baseline - font_measurements.descender_height
|
||||
else:
|
||||
raise ValueError(valign)
|
||||
|
||||
return (anchor_x, anchor_y), line_xs, line_ys
|
||||
|
||||
|
||||
def _get_wcs_insert(text: AnyText) -> Vec3:
|
||||
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
|
||||
insert: Vec3 = text.dxf.insert
|
||||
align_point: Vec3 = text.dxf.align_point
|
||||
alignment: TextEntityAlignment = text.get_align_enum()
|
||||
if alignment == TextEntityAlignment.LEFT:
|
||||
# LEFT/BASELINE is always located at the insert point.
|
||||
pass
|
||||
elif alignment in (
|
||||
TextEntityAlignment.FIT,
|
||||
TextEntityAlignment.ALIGNED,
|
||||
):
|
||||
# Interpolate insertion location between insert and align point:
|
||||
insert = insert.lerp(align_point, factor=0.5)
|
||||
else:
|
||||
# Everything else is located at the align point:
|
||||
insert = align_point
|
||||
return text.ocs().to_wcs(insert)
|
||||
else:
|
||||
return text.dxf.insert
|
||||
|
||||
|
||||
# Simple but fast MTEXT renderer:
|
||||
def simplified_text_chunks(
|
||||
text: AnyText,
|
||||
render_engine: TextRenderer,
|
||||
*,
|
||||
font_face: fonts.FontFace,
|
||||
) -> Iterable[tuple[str, Matrix44, float]]:
|
||||
"""Splits a complex text entity into simple chunks of text which can all be
|
||||
rendered the same way:
|
||||
render the string (which will not contain any newlines) with the given
|
||||
cap_height with (left, baseline) at (0, 0) then transform it with the given
|
||||
matrix to move it into place.
|
||||
"""
|
||||
alignment = _get_alignment(text)
|
||||
box_width = _get_text_width(text)
|
||||
|
||||
cap_height = _get_cap_height(text)
|
||||
lines = _split_into_lines(
|
||||
text,
|
||||
box_width,
|
||||
lambda s: render_engine.get_text_line_width(s, font_face, cap_height),
|
||||
)
|
||||
line_spacing = _get_line_spacing(text, cap_height)
|
||||
line_widths = [
|
||||
render_engine.get_text_line_width(line, font_face, cap_height) for line in lines
|
||||
]
|
||||
font_measurements = render_engine.get_font_measurements(font_face, cap_height)
|
||||
anchor, line_xs, line_ys = _apply_alignment(
|
||||
alignment, line_widths, line_spacing, box_width, font_measurements
|
||||
)
|
||||
rotation = _get_rotation(text)
|
||||
|
||||
# first_line_width is used for TEXT, ATTRIB and ATTDEF stretching
|
||||
if line_widths:
|
||||
first_line_width = line_widths[0]
|
||||
else: # no text lines -> no output, value is not important
|
||||
first_line_width = 1.0
|
||||
|
||||
extra_transform = _get_extra_transform(text, first_line_width)
|
||||
insert = _get_wcs_insert(text)
|
||||
|
||||
whole_text_transform = (
|
||||
Matrix44.translate(-anchor[0], -anchor[1], 0)
|
||||
@ extra_transform
|
||||
@ rotation
|
||||
@ Matrix44.translate(*insert.xyz)
|
||||
)
|
||||
for i, (line, line_x, line_y) in enumerate(zip(lines, line_xs, line_ys)):
|
||||
transform = Matrix44.translate(line_x, line_y, 0) @ whole_text_transform
|
||||
yield line, transform, cap_height
|
||||
@@ -0,0 +1,46 @@
|
||||
# Copyright (c) 2022-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TypeVar, TYPE_CHECKING
|
||||
import abc
|
||||
from ezdxf.fonts import fonts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.npshapes import NumpyPath2d
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class TextRenderer(abc.ABC):
|
||||
"""Minimal requirement to be usable as a universal text renderer"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_font_measurements(
|
||||
self, font_face: fonts.FontFace, cap_height: float = 1.0
|
||||
) -> fonts.FontMeasurements:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_text_line_width(
|
||||
self,
|
||||
text: str,
|
||||
font_face: fonts.FontFace,
|
||||
cap_height: float = 1.0,
|
||||
) -> float:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_text_path(
|
||||
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
|
||||
) -> NumpyPath2d:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_text_glyph_paths(
|
||||
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
|
||||
) -> list[NumpyPath2d]:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_stroke_font(self, font_face: fonts.FontFace) -> bool:
|
||||
...
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) 2020-2022, Matthew Broadway
|
||||
# License: MIT License
|
||||
from typing import Callable
|
||||
from typing_extensions import TypeAlias
|
||||
from ezdxf.entities import DXFGraphic
|
||||
|
||||
LayerName: TypeAlias = str
|
||||
Color: TypeAlias = str
|
||||
Radians: TypeAlias = float
|
||||
FilterFunc: TypeAlias = Callable[[DXFGraphic], bool]
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from ezdxf.fonts import fonts
|
||||
from ezdxf.fonts.font_measurements import FontMeasurements
|
||||
|
||||
from .text_renderer import TextRenderer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.npshapes import NumpyPath2d
|
||||
|
||||
|
||||
class UnifiedTextRenderer(TextRenderer):
|
||||
"""This text renderer supports .ttf, .ttc, .otf, .shx, .shp and .lff fonts.
|
||||
|
||||
The resolving order for .shx fonts is applied in the RenderContext.add_text_style()
|
||||
method.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._font_cache: dict[str, fonts.AbstractFont] = dict()
|
||||
|
||||
def get_font(self, font_face: fonts.FontFace) -> fonts.AbstractFont:
|
||||
if not font_face.filename and font_face.family:
|
||||
found = fonts.find_best_match(
|
||||
family=font_face.family,
|
||||
weight=700 if font_face.is_bold else 400,
|
||||
italic=font_face.is_italic,
|
||||
)
|
||||
if found is not None:
|
||||
font_face = found
|
||||
key = font_face.filename.lower()
|
||||
try:
|
||||
return self._font_cache[key]
|
||||
except KeyError:
|
||||
pass
|
||||
abstract_font = fonts.make_font(font_face.filename, 1.0)
|
||||
self._font_cache[key] = abstract_font
|
||||
return abstract_font
|
||||
|
||||
def is_stroke_font(self, font_face: fonts.FontFace) -> bool:
|
||||
abstract_font = self.get_font(font_face)
|
||||
return abstract_font.font_render_type == fonts.FontRenderType.STROKE
|
||||
|
||||
def get_font_measurements(
|
||||
self, font_face: fonts.FontFace, cap_height: float = 1.0
|
||||
) -> FontMeasurements:
|
||||
abstract_font = self.get_font(font_face)
|
||||
return abstract_font.measurements.scale(cap_height)
|
||||
|
||||
def get_text_path(
|
||||
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
|
||||
) -> NumpyPath2d:
|
||||
abstract_font = self.get_font(font_face)
|
||||
return abstract_font.text_path_ex(text, cap_height)
|
||||
|
||||
def get_text_glyph_paths(
|
||||
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
|
||||
) -> list[NumpyPath2d]:
|
||||
abstract_font = self.get_font(font_face)
|
||||
return abstract_font.text_glyph_paths(text, cap_height)
|
||||
|
||||
def get_text_line_width(
|
||||
self,
|
||||
text: str,
|
||||
font_face: fonts.FontFace,
|
||||
cap_height: float = 1.0,
|
||||
) -> float:
|
||||
abstract_font = self.get_font(font_face)
|
||||
return abstract_font.text_width_ex(text, cap_height)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2020-2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
|
||||
from .loader import load, readfile
|
||||
from .fileheader import FileHeader
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) 2020-2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from typing import Iterable, Tuple
|
||||
import struct
|
||||
|
||||
from ezdxf.tools.binarydata import BitStream
|
||||
from ezdxf.entities import DXFClass
|
||||
|
||||
from .const import *
|
||||
from .crc import crc8
|
||||
from .fileheader import FileHeader
|
||||
from .header_section import DwgSectionLoader
|
||||
|
||||
|
||||
def load_classes_section(specs: FileHeader, data: Bytes, crc_check=False):
|
||||
if specs.version <= ACAD_2000:
|
||||
return DwgClassesSectionR2000(specs, data, crc_check)
|
||||
else:
|
||||
return DwgClassesSectionR2004(specs, data, crc_check)
|
||||
|
||||
|
||||
class DwgClassesSectionR2000(DwgSectionLoader):
|
||||
def load_data_section(self, data: Bytes) -> Bytes:
|
||||
if self.specs.version > ACAD_2000:
|
||||
raise DwgVersionError(self.specs.version)
|
||||
seeker, section_size = self.specs.sections[CLASSES_ID]
|
||||
return data[seeker : seeker + section_size]
|
||||
|
||||
def load_classes(self) -> Iterable[Tuple[int, DXFClass]]:
|
||||
sentinel = self.data[:SENTINEL_SIZE]
|
||||
if (
|
||||
sentinel
|
||||
!= b"\x8D\xA1\xC4\xB8\xC4\xA9\xF8\xC5\xC0\xDC\xF4\x5F\xE7\xCF\xB6\x8A"
|
||||
):
|
||||
raise DwgCorruptedClassesSection(
|
||||
"Sentinel for start of CLASSES section not found."
|
||||
)
|
||||
start_index = SENTINEL_SIZE
|
||||
bs = BitStream(
|
||||
self.data[start_index:],
|
||||
dxfversion=self.specs.version,
|
||||
encoding=self.specs.encoding,
|
||||
)
|
||||
class_data_size = bs.read_unsigned_long() # data size in bytes
|
||||
end_sentinel_index = SENTINEL_SIZE + 6 + class_data_size
|
||||
end_index = end_sentinel_index - 2
|
||||
end_bit_index = (3 + class_data_size) << 3
|
||||
|
||||
while bs.bit_index < end_bit_index:
|
||||
class_num = bs.read_bit_short()
|
||||
dxfattribs = {
|
||||
"flags": bs.read_bit_short(), # version?
|
||||
"app_name": bs.read_text(),
|
||||
"cpp_class_name": bs.read_text(),
|
||||
"name": bs.read_text(),
|
||||
"was_a_proxy": bs.read_bit(),
|
||||
"is_an_entity": int(bs.read_bit_short() == 0x1F2),
|
||||
}
|
||||
yield class_num, DXFClass.new(dxfattribs=dxfattribs)
|
||||
|
||||
if self.crc_check and False:
|
||||
check = struct.unpack_from("<H", self.data, end_index)[0]
|
||||
# TODO: classes crc check
|
||||
# Which data should be checked? This is not correct:
|
||||
crc = crc8(self.data[start_index:end_index])
|
||||
if check != crc:
|
||||
raise CRCError("CRC error in classes section.")
|
||||
sentinel = self.data[
|
||||
end_sentinel_index : end_sentinel_index + SENTINEL_SIZE
|
||||
]
|
||||
if (
|
||||
sentinel
|
||||
!= b"\x72\x5E\x3B\x47\x3B\x56\x07\x3A\x3F\x23\x0B\xA0\x18\x30\x49\x75"
|
||||
):
|
||||
raise DwgCorruptedClassesSection(
|
||||
"Sentinel for end of CLASSES section not found."
|
||||
)
|
||||
|
||||
|
||||
class DwgClassesSectionR2004(DwgClassesSectionR2000):
|
||||
def load_data(self, data: Bytes) -> Bytes:
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copyright (c) 2020-2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from typing import Union
|
||||
|
||||
ACAD_13 = "AC1012"
|
||||
ACAD_14 = "AC1014"
|
||||
ACAD_2000 = "AC1015"
|
||||
ACAD_2004 = "AC1018"
|
||||
ACAD_2007 = "AC1021"
|
||||
ACAD_2010 = "AC1024"
|
||||
ACAD_2013 = "AC1027"
|
||||
ACAD_2018 = "AC1032"
|
||||
ACAD_LATEST = ACAD_2018
|
||||
|
||||
SUPPORTED_VERSIONS = [ACAD_13, ACAD_14, ACAD_2000]
|
||||
HEADER_ID = 0
|
||||
CLASSES_ID = 1
|
||||
OBJECTS_ID = 2
|
||||
SENTINEL_SIZE = 16
|
||||
|
||||
Bytes = Union[bytes, bytearray, memoryview]
|
||||
|
||||
|
||||
class DwgError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DwgVersionError(DwgError):
|
||||
pass
|
||||
|
||||
|
||||
class DwgCorruptedFileHeader(DwgError):
|
||||
pass
|
||||
|
||||
|
||||
class DwgCorruptedClassesSection(DwgError):
|
||||
pass
|
||||
|
||||
|
||||
class DwgCorruptedHeaderSection(DwgError):
|
||||
pass
|
||||
|
||||
|
||||
class CRCError(DwgError):
|
||||
pass
|
||||
@@ -0,0 +1,83 @@
|
||||
# Copyright (c) 2020-2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
|
||||
__all__ = ["crc8", "crc32"]
|
||||
|
||||
from .const import Bytes
|
||||
|
||||
|
||||
def crc8(data: Bytes, seed: int = 0) -> int:
|
||||
for byte in data:
|
||||
index = byte ^ (seed & 0xFF)
|
||||
seed = (seed >> 8) & 0xFF
|
||||
seed ^= CRC8_TABLE[index & 0xFF]
|
||||
return seed
|
||||
|
||||
|
||||
def crc32(data: Bytes, seed: int = 0) -> int:
|
||||
inverted_crc = ~seed
|
||||
for byte in data:
|
||||
inverted_crc = (inverted_crc >> 8) ^ CRC32_TABLE[
|
||||
(inverted_crc ^ byte) & 0xFF
|
||||
]
|
||||
return ~inverted_crc
|
||||
|
||||
|
||||
# fmt: off
|
||||
# Source: Open Design Specification for .dwg
|
||||
CRC8_TABLE = [
|
||||
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1,
|
||||
0xC481, 0x0440, 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, 0x0A00, 0xCAC1, 0xCB81, 0x0B40,
|
||||
0xC901, 0x09C0, 0x0880, 0xC841, 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, 0x1E00, 0xDEC1,
|
||||
0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
|
||||
0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1,
|
||||
0xF281, 0x3240, 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, 0x3C00, 0xFCC1, 0xFD81, 0x3D40,
|
||||
0xFF01, 0x3FC0, 0x3E80, 0xFE41, 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, 0x2800, 0xE8C1,
|
||||
0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
|
||||
0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0,
|
||||
0x2080, 0xE041, 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, 0x6600, 0xA6C1, 0xA781, 0x6740,
|
||||
0xA501, 0x65C0, 0x6480, 0xA441, 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, 0xAA01, 0x6AC0,
|
||||
0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
|
||||
0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1,
|
||||
0xB681, 0x7640, 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, 0x5000, 0x90C1, 0x9181, 0x5140,
|
||||
0x9301, 0x53C0, 0x5280, 0x9241, 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, 0x9C01, 0x5CC0,
|
||||
0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
|
||||
0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0,
|
||||
0x4C80, 0x8C41, 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, 0x8201, 0x42C0, 0x4380, 0x8341,
|
||||
0x4100, 0x81C1, 0x8081, 0x4040,
|
||||
]
|
||||
|
||||
# Source: Open Design Specification for .dwg
|
||||
CRC32_TABLE = [
|
||||
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832,
|
||||
0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2,
|
||||
0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a,
|
||||
0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
|
||||
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3,
|
||||
0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423,
|
||||
0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab,
|
||||
0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
|
||||
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4,
|
||||
0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950,
|
||||
0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce, 0xa3bc0074,
|
||||
0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
|
||||
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525,
|
||||
0x206f85b3, 0xb966d409, 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
|
||||
0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615,
|
||||
0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
|
||||
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76,
|
||||
0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e,
|
||||
0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, 0xd80d2bda, 0xaf0a1b4c, 0x36034af6,
|
||||
0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
|
||||
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7,
|
||||
0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f,
|
||||
0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7,
|
||||
0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
|
||||
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278,
|
||||
0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc,
|
||||
0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330,
|
||||
0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
|
||||
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d,
|
||||
]
|
||||
|
||||
# fmt: on
|
||||
@@ -0,0 +1,92 @@
|
||||
# Copyright (c) 2020-2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from typing import Dict, Tuple
|
||||
import struct
|
||||
from .const import *
|
||||
from .crc import crc8
|
||||
|
||||
codepage_to_encoding = {
|
||||
37: "cp874", # Thai,
|
||||
38: "cp932", # Japanese
|
||||
39: "gbk", # UnifiedChinese
|
||||
40: "cp949", # Korean
|
||||
41: "cp950", # TradChinese
|
||||
28: "cp1250", # CentralEurope
|
||||
29: "cp1251", # Cyrillic
|
||||
30: "cp1252", # WesternEurope
|
||||
32: "cp1253", # Greek
|
||||
33: "cp1254", # Turkish
|
||||
34: "cp1255", # Hebrew
|
||||
35: "cp1256", # Arabic
|
||||
36: "cp1257", # Baltic
|
||||
}
|
||||
|
||||
FILE_HEADER_MAGIC = {
|
||||
3: 0xA598,
|
||||
4: 0x8101,
|
||||
5: 0x3CC4,
|
||||
6: 0x8461,
|
||||
}
|
||||
|
||||
|
||||
class FileHeader:
|
||||
def __init__(self, data: Bytes, crc_check=False):
|
||||
self.crc_check = crc_check
|
||||
if len(data) < 6:
|
||||
raise DwgVersionError("Not a DWG file.")
|
||||
ver = data[:6].decode(errors="ignore") # type: ignore
|
||||
if ver not in SUPPORTED_VERSIONS:
|
||||
raise DwgVersionError(
|
||||
f"Not a DWG file or unsupported DWG version, signature: {ver}."
|
||||
)
|
||||
self.version: str = ver
|
||||
codepage: int = struct.unpack_from("<h", data, 0x13)[0]
|
||||
self.encoding = codepage_to_encoding.get(codepage, "cp1252")
|
||||
self.maintenance_release_version = data[0xB]
|
||||
self.sections: Dict[int, Tuple[int, int]] = dict()
|
||||
if self.version <= ACAD_2000:
|
||||
self.r2000_header(data)
|
||||
else:
|
||||
raise DwgVersionError(self.version)
|
||||
|
||||
def r2000_header(self, data: Bytes):
|
||||
index = 0x15
|
||||
section_count: int = struct.unpack_from("<L", data, index)[0]
|
||||
index += 4
|
||||
fmt = "<BLL"
|
||||
record_size = struct.calcsize(fmt)
|
||||
for record in range(section_count):
|
||||
# 0: HEADER_ID
|
||||
# 1: CLASSES_ID
|
||||
# 2: OBJECTS_ID
|
||||
num, seeker, size = struct.unpack_from(fmt, data, index)
|
||||
index += record_size
|
||||
self.sections[num] = (seeker, size)
|
||||
|
||||
if self.crc_check:
|
||||
# CRC from first byte of file until start of crc value
|
||||
check = (
|
||||
crc8(data[:index], seed=0)
|
||||
^ FILE_HEADER_MAGIC[len(self.sections)]
|
||||
)
|
||||
crc = struct.unpack_from("<H", data, index)[0]
|
||||
if crc != check:
|
||||
raise CRCError("CRC error in file header.")
|
||||
|
||||
index += 2
|
||||
sentinel = data[index : index + SENTINEL_SIZE]
|
||||
if (
|
||||
sentinel
|
||||
!= b"\x95\xA0\x4E\x28\x99\x82\x1A\xE5\x5E\x41\xE0\x5F\x9D\x3A\x4D\x00"
|
||||
):
|
||||
raise DwgCorruptedFileHeader(
|
||||
"Corrupted DXF R13/14/2000 file header."
|
||||
)
|
||||
|
||||
def print(self):
|
||||
print(f"DWG version: {self.version}")
|
||||
print(f"encoding: {self.encoding}")
|
||||
print(f"Records: {len(self.sections)}")
|
||||
print("Header: seeker {0[0]} size: {0[1]}".format(self.sections[0]))
|
||||
print("Classes: seeker {0[0]} size: {0[1]}".format(self.sections[1]))
|
||||
print("Objects: seeker {0[0]} size: {0[1]}".format(self.sections[2]))
|
||||
@@ -0,0 +1,657 @@
|
||||
# Copyright (c) 2020-2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from abc import abstractmethod
|
||||
import struct
|
||||
|
||||
from ezdxf.lldxf.const import acad_release_to_dxf_version
|
||||
from ezdxf.tools.binarydata import BitStream
|
||||
|
||||
from .const import *
|
||||
from .crc import crc8
|
||||
from .fileheader import FileHeader
|
||||
|
||||
|
||||
def load_header_section(specs: FileHeader, data: Bytes, crc_check=False):
|
||||
if specs.version <= ACAD_2000:
|
||||
return DwgHeaderSectionR2000(specs, data, crc_check)
|
||||
else:
|
||||
return DwgHeaderSectionR2004(specs, data, crc_check)
|
||||
|
||||
|
||||
class DwgSectionLoader:
|
||||
def __init__(self, specs: FileHeader, data: Bytes, crc_check=False):
|
||||
self.specs = specs
|
||||
self.crc_check = crc_check
|
||||
self.data = self.load_data_section(data)
|
||||
|
||||
@abstractmethod
|
||||
def load_data_section(self, data: Bytes) -> Bytes:
|
||||
...
|
||||
|
||||
|
||||
class DwgHeaderSectionR2000(DwgSectionLoader):
|
||||
def load_data_section(self, data: Bytes) -> Bytes:
|
||||
if self.specs.version > ACAD_2000:
|
||||
raise DwgVersionError(self.specs.version)
|
||||
seeker, section_size = self.specs.sections[HEADER_ID]
|
||||
return data[seeker : seeker + section_size]
|
||||
|
||||
def load_header_vars(self) -> Dict:
|
||||
data = self.data
|
||||
sentinel = data[:16]
|
||||
if (
|
||||
sentinel
|
||||
!= b"\xCF\x7B\x1F\x23\xFD\xDE\x38\xA9\x5F\x7C\x68\xB8\x4E\x6D\x33\x5F"
|
||||
):
|
||||
raise DwgCorruptedHeaderSection(
|
||||
"Sentinel for start of HEADER section not found."
|
||||
)
|
||||
index = 16
|
||||
size = struct.unpack_from("<L", data, index)[0]
|
||||
index += 4
|
||||
bs = BitStream(
|
||||
data[index : index + size],
|
||||
dxfversion=self.specs.version,
|
||||
encoding=self.specs.encoding,
|
||||
)
|
||||
hdr_vars = parse_header(bs)
|
||||
index += size
|
||||
if self.crc_check:
|
||||
check = struct.unpack_from("<H", data, index)[0]
|
||||
# CRC of data from end of sentinel until start of crc value
|
||||
crc = crc8(data[16:-18], seed=0xC0C1)
|
||||
if check != crc:
|
||||
raise CRCError("CRC error in header section.")
|
||||
sentinel = data[-16:]
|
||||
if (
|
||||
sentinel
|
||||
!= b"\x30\x84\xE0\xDC\x02\x21\xC7\x56\xA0\x83\x97\x47\xB1\x92\xCC\xA0"
|
||||
):
|
||||
raise DwgCorruptedHeaderSection(
|
||||
"Sentinel for end of HEADER section not found."
|
||||
)
|
||||
return hdr_vars
|
||||
|
||||
|
||||
class DwgHeaderSectionR2004(DwgHeaderSectionR2000):
|
||||
def load_data(self, data: Bytes) -> Bytes:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
CMD_SET_VERSION = "ver"
|
||||
CMD_SKIP_BITS = "skip_bits"
|
||||
CMD_SKIP_NEXT_IF = "skip_next_if"
|
||||
CMD_SET_VAR = "var"
|
||||
|
||||
|
||||
def _min_max_versions(version: str) -> Tuple[str, str]:
|
||||
min_ver = ACAD_13
|
||||
max_ver = ACAD_LATEST
|
||||
if version != "all":
|
||||
v = version.split("-")
|
||||
if len(v) > 1:
|
||||
min_ver = acad_release_to_dxf_version[v[0].strip()]
|
||||
max_ver = acad_release_to_dxf_version[v[1].strip()]
|
||||
else:
|
||||
v_str: str = v[0].strip()
|
||||
if v_str[-1] == "+":
|
||||
min_ver = acad_release_to_dxf_version[v_str[:-1]]
|
||||
else:
|
||||
min_ver = max_ver = acad_release_to_dxf_version[v_str]
|
||||
return min_ver, max_ver
|
||||
|
||||
|
||||
def load_commands(desc: str) -> List[Tuple[str, Any]]:
|
||||
commands = []
|
||||
lines = desc.split("\n")
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line[0] == "#":
|
||||
continue
|
||||
try:
|
||||
command, param = line.split(":")
|
||||
except ValueError:
|
||||
raise ValueError(f"Unpack Error in line: {line}")
|
||||
command = command.strip()
|
||||
param = param.split("#")[0].strip()
|
||||
if command == CMD_SET_VERSION:
|
||||
commands.append((CMD_SET_VERSION, _min_max_versions(param)))
|
||||
elif command in {CMD_SKIP_BITS, CMD_SKIP_NEXT_IF}:
|
||||
commands.append((command, param)) # type: ignore
|
||||
elif command[0] == "$":
|
||||
commands.append((CMD_SET_VAR, (command, param)))
|
||||
else:
|
||||
raise ValueError(f"Unknown command: {command}")
|
||||
return commands
|
||||
|
||||
|
||||
def parse_bitstream(
|
||||
bs: BitStream, commands: List[Tuple[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
version = bs.dxfversion
|
||||
min_ver = ACAD_13
|
||||
max_ver = ACAD_LATEST
|
||||
hdr_vars: Dict[str, Any] = dict()
|
||||
skip_next_cmd = False
|
||||
for cmd, params in commands:
|
||||
if skip_next_cmd:
|
||||
skip_next_cmd = False
|
||||
continue
|
||||
|
||||
if cmd == CMD_SET_VERSION:
|
||||
min_ver, max_ver = params
|
||||
elif cmd == CMD_SKIP_BITS:
|
||||
bs.skip(int(params))
|
||||
elif cmd == CMD_SKIP_NEXT_IF:
|
||||
skip_next_cmd = eval(params, None, {"header": hdr_vars})
|
||||
elif cmd == CMD_SET_VAR:
|
||||
if min_ver <= version <= max_ver:
|
||||
name, code = params
|
||||
hdr_vars[name] = bs.read_code(code)
|
||||
else:
|
||||
raise ValueError(f"Unknown command: {cmd}")
|
||||
return hdr_vars
|
||||
|
||||
|
||||
def parse_header(bs: BitStream) -> Dict[str, Any]:
|
||||
commands = load_commands(HEADER_DESCRIPTION)
|
||||
return parse_bitstream(bs, commands)
|
||||
|
||||
|
||||
HEADER_DESCRIPTION = """
|
||||
ver: R2007
|
||||
$SIZE_IN_BITS: RL # Size in bits
|
||||
|
||||
ver: R2013+
|
||||
$REQUIREDVERSIONS: BLL # default value 0, read only
|
||||
|
||||
ver: all
|
||||
$UNKNOWN: BD # Unknown, default value 412148564080.0
|
||||
$UNKNOWN: BD # Unknown, default value 1.0
|
||||
$UNKNOWN: BD # Unknown, default value 1.0
|
||||
$UNKNOWN: BD # Unknown, default value 1.0
|
||||
$UNKNOWN: TV # Unknown text string, default ""
|
||||
$UNKNOWN: TV # Unknown text string, default ""
|
||||
$UNKNOWN: TV # Unknown text string, default ""
|
||||
$UNKNOWN: TV # Unknown text string, default ""
|
||||
$UNKNOWN: BL # Unknown long, default value 24L
|
||||
$UNKNOWN: BL # Unknown long, default value 0L;
|
||||
|
||||
ver: R13-R14
|
||||
$UNKNOWN: BS # Unknown short, default value 0
|
||||
|
||||
ver: R13-R2000
|
||||
$CURRENT_VIEWPORT_ENTITY_HEADER: H # Handle of the current viewport entity header (hard pointer)
|
||||
|
||||
ver: all
|
||||
$DIMASO: B
|
||||
$DIMSHO: B
|
||||
|
||||
ver: R13-R14
|
||||
$DIMSAV: B # Undocumented
|
||||
|
||||
ver: all
|
||||
$PLINEGEN: B
|
||||
$ORTHOMODE: B
|
||||
$REGENMODE: B
|
||||
$FILLMODE: B
|
||||
$QTEXTMODE: B
|
||||
$PSLTSCALE: B
|
||||
$LIMCHECK: B
|
||||
|
||||
ver: R13-R14
|
||||
$BLIPMODE: B
|
||||
|
||||
ver: R2004+
|
||||
$UNKNOWN: B # Undocumented
|
||||
|
||||
ver: all
|
||||
$USRTIMER: B # (User timer on/off)
|
||||
$SKPOLY: B
|
||||
$ANGDIR: B
|
||||
$SPLFRAME: B
|
||||
|
||||
ver: R13-R14
|
||||
$ATTREQ: B
|
||||
$ATTDIA: B
|
||||
|
||||
ver: all
|
||||
$MIRRTEXT: B
|
||||
$WORLDVIEW: B
|
||||
|
||||
ver: R13-R14
|
||||
$WIREFRAME: B # Undocumented.
|
||||
|
||||
ver: all
|
||||
$TILEMODE: B
|
||||
$PLIMCHECK: B
|
||||
$VISRETAIN: B
|
||||
|
||||
ver: R13-R14
|
||||
$DELOBJ: B
|
||||
|
||||
ver: all
|
||||
$DISPSILH: B
|
||||
$PELLIPSE: B # (not present in DXF)
|
||||
$PROXYGRAPHICS: BS
|
||||
|
||||
ver: R13-R14
|
||||
$DRAGMODE: BS
|
||||
|
||||
ver: all
|
||||
$TREEDEPTH: BS
|
||||
$LUNITS: BS
|
||||
$LUPREC: BS
|
||||
$AUNITS: BS
|
||||
$AUPREC: BS
|
||||
|
||||
ver: R13-R14
|
||||
$OSMODE: BS
|
||||
|
||||
ver: all
|
||||
$ATTMODE: BS
|
||||
|
||||
ver: R13-R14
|
||||
$COORDS: BS
|
||||
|
||||
ver: all
|
||||
$PDMODE: BS
|
||||
|
||||
ver: R13-R14
|
||||
$PICKSTYLE: BS
|
||||
|
||||
ver: R2004+
|
||||
$UNKNOWN: BL
|
||||
$UNKNOWN: BL
|
||||
$UNKNOWN: BL
|
||||
|
||||
ver: all
|
||||
$USERI1: BS
|
||||
$USERI2: BS
|
||||
$USERI3: BS
|
||||
$USERI4: BS
|
||||
$USERI5: BS
|
||||
$SPLINESEGS: BS
|
||||
$SURFU: BS
|
||||
$SURFV: BS
|
||||
$SURFTYPE: BS
|
||||
$SURFTAB1: BS
|
||||
$SURFTAB2: BS
|
||||
$SPLINETYPE: BS
|
||||
$SHADEDGE: BS
|
||||
$SHADEDIF: BS
|
||||
$UNITMODE: BS
|
||||
$MAXACTVP: BS
|
||||
$ISOLINES: BS
|
||||
$CMLJUST: BS
|
||||
$TEXTQLTY: BS
|
||||
$LTSCALE: BD
|
||||
$TEXTSIZE: BD
|
||||
$TRACEWID: BD
|
||||
$SKETCHINC: BD
|
||||
$FILLETRAD: BD
|
||||
$THICKNESS: BD
|
||||
$ANGBASE: BD
|
||||
$PDSIZE: BD
|
||||
$PLINEWID: BD
|
||||
$USERR1: BD
|
||||
$USERR2: BD
|
||||
$USERR3: BD
|
||||
$USERR4: BD
|
||||
$USERR5: BD
|
||||
$CHAMFERA: BD
|
||||
$CHAMFERB: BD
|
||||
$CHAMFERC: BD
|
||||
$CHAMFERD: BD
|
||||
$FACETRES: BD
|
||||
$CMLSCALE: BD
|
||||
$CELTSCALE: BD
|
||||
|
||||
ver: R13-R2004
|
||||
$MENUNAME: TV
|
||||
|
||||
ver: all
|
||||
$TDCREATE: BL # (Julian day)
|
||||
$TDCREATE: BL # (Milliseconds into the day)
|
||||
$TDUPDATE: BL # (Julian day)
|
||||
$TDUPDATE: BL # (Milliseconds into the day)
|
||||
|
||||
ver: R2004+
|
||||
$UNKNOWN: BL
|
||||
$UNKNOWN: BL
|
||||
$UNKNOWN: BL
|
||||
|
||||
ver: all
|
||||
$TDINDWG: BL # (Days)
|
||||
$TDINDWG: BL # (Milliseconds into the day)
|
||||
$TDUSRTIMER: BL # (Days)
|
||||
$TDUSRTIMER: BL # (Milliseconds into the day)
|
||||
$CECOLOR: CMC
|
||||
|
||||
# with an 8-bit length specifier preceding the handle bytes (standard hex handle form) (code 0).
|
||||
# The HANDSEED is not part of the handle stream, but of the normal data stream (relevant for R21 and later).
|
||||
|
||||
$HANDSEED: H # The next handle
|
||||
$CLAYER: H # (hard pointer)
|
||||
$TEXTSTYLE: H # (hard pointer)
|
||||
$CELTYPE: H # (hard pointer)
|
||||
|
||||
ver: R2007+
|
||||
$CMATERIAL: H # (hard pointer)
|
||||
|
||||
ver: all
|
||||
$DIMSTYLE: H # (hard pointer)
|
||||
$CMLSTYLE: H # (hard pointer)
|
||||
|
||||
ver: R2000+
|
||||
$PSVPSCALE: BD
|
||||
|
||||
ver: all
|
||||
$PINSBASE: 3BD # (PSPACE)
|
||||
$PEXTMIN: 3BD # (PSPACE)
|
||||
$PEXTMAX: 3BD # (PSPACE)
|
||||
$PLIMMIN: 2RD # (PSPACE)
|
||||
$PLIMMAX: 2RD # (PSPACE)
|
||||
$PELEVATION: BD # (PSPACE)
|
||||
$PUCSORG: 3BD # (PSPACE)
|
||||
$PUCSXDIR: 3BD # (PSPACE)
|
||||
$PUCSYDIR: 3BD # (PSPACE)
|
||||
$PUCSNAME: H # (PSPACE) (hard pointer)
|
||||
|
||||
ver: R2000+
|
||||
$PUCSORTHOREF: H # (hard pointer)
|
||||
$PUCSORTHOVIEW: BS
|
||||
$PUCSBASE: H # (hard pointer)
|
||||
$PUCSORGTOP: 3BD
|
||||
$PUCSORGBOTTOM: 3BD
|
||||
$PUCSORGLEFT: 3BD
|
||||
$PUCSORGRIGHT: 3BD
|
||||
$PUCSORGFRONT: 3BD
|
||||
$PUCSORGBACK: 3BD
|
||||
|
||||
ver: all
|
||||
$INSBASE: 3BD # (MSPACE)
|
||||
$EXTMIN: 3BD # (MSPACE)
|
||||
$EXTMAX: 3BD # (MSPACE)
|
||||
$LIMMIN: 2RD # (MSPACE)
|
||||
$LIMMAX: 2RD # (MSPACE)
|
||||
$ELEVATION: BD # (MSPACE)
|
||||
$UCSORG: 3BD # (MSPACE)
|
||||
$UCSXDIR: 3BD # (MSPACE)
|
||||
$UCSYDIR: 3BD # (MSPACE)
|
||||
$UCSNAME: H # (MSPACE) (hard pointer)
|
||||
|
||||
ver: R2000+
|
||||
$UCSORTHOREF: H # (hard pointer)
|
||||
$UCSORTHOVIEW: BS
|
||||
$UCSBASE: H # (hard pointer)
|
||||
$UCSORGTOP: 3BD
|
||||
$UCSORGBOTTOM: 3BD
|
||||
$UCSORGLEFT: 3BD
|
||||
$UCSORGRIGHT: 3BD
|
||||
$UCSORGFRONT: 3BD
|
||||
$UCSORGBACK: 3BD
|
||||
$DIMPOST: TV
|
||||
$DIMAPOST: TV
|
||||
|
||||
ver: R13-R14
|
||||
$DIMTOL: B
|
||||
$DIMLIM: B
|
||||
$DIMTIH: B
|
||||
$DIMTOH: B
|
||||
$DIMSE1: B
|
||||
$DIMSE2: B
|
||||
$DIMALT: B
|
||||
$DIMTOFL: B
|
||||
$DIMSAH: B
|
||||
$DIMTIX: B
|
||||
$DIMSOXD: B
|
||||
$DIMALTD: RC
|
||||
$DIMZIN: RC
|
||||
$DIMSD1: B
|
||||
$DIMSD2: B
|
||||
$DIMTOLJ: RC
|
||||
$DIMJUST: RC
|
||||
$DIMFIT: RC
|
||||
$DIMUPT: B
|
||||
$DIMTZIN: RC
|
||||
$DIMALTZ: RC
|
||||
$DIMALTTZ: RC
|
||||
$DIMTAD: RC
|
||||
$DIMUNIT: BS
|
||||
$DIMAUNIT: BS
|
||||
$DIMDEC: BS
|
||||
$DIMTDEC: BS
|
||||
$DIMALTU: BS
|
||||
$DIMALTTD: BS
|
||||
$DIMTXSTY: H # (hard pointer)
|
||||
|
||||
ver: all
|
||||
$DIMSCALE: BD
|
||||
$DIMASZ: BD
|
||||
$DIMEXO: BD
|
||||
$DIMDLI: BD
|
||||
$DIMEXE: BD
|
||||
$DIMRND: BD
|
||||
$DIMDLE: BD
|
||||
$DIMTP: BD
|
||||
$DIMTM: BD
|
||||
|
||||
ver: R2007+
|
||||
$DIMFXL: BD
|
||||
$DIMJOGANG: BD
|
||||
$DIMTFILL: BS
|
||||
$DIMTFILLCLR: CMC
|
||||
|
||||
ver: R2000+
|
||||
$DIMTOL: B
|
||||
$DIMLIM: B
|
||||
$DIMTIH: B
|
||||
$DIMTOH: B
|
||||
$DIMSE1: B
|
||||
$DIMSE2: B
|
||||
$DIMTAD: BS
|
||||
$DIMZIN: BS
|
||||
$DIMAZIN: BS
|
||||
|
||||
ver: R2007+
|
||||
$DIMARCSYM: BS
|
||||
|
||||
ver: all
|
||||
$DIMTXT: BD
|
||||
$DIMCEN: BD
|
||||
$DIMTSZ: BD
|
||||
$DIMALTF: BD
|
||||
$DIMLFAC: BD
|
||||
$DIMTVP: BD
|
||||
$DIMTFAC: BD
|
||||
$DIMGAP: BD
|
||||
|
||||
ver: R13-R14
|
||||
$DIMPOST: T
|
||||
$DIMAPOST: T
|
||||
$DIMBLK: T
|
||||
$DIMBLK1: T
|
||||
$DIMBLK2: T
|
||||
|
||||
ver: R2000+
|
||||
$DIMALTRND: BD
|
||||
$DIMALT: B
|
||||
$DIMALTD: BS
|
||||
$DIMTOFL: B
|
||||
$DIMSAH: B
|
||||
$DIMTIX: B
|
||||
$DIMSOXD: B
|
||||
|
||||
ver: all
|
||||
$DIMCLRD: CMC
|
||||
$DIMCLRE: CMC
|
||||
$DIMCLRT: CMC
|
||||
|
||||
ver: R2000+
|
||||
$DIMADEC: BS
|
||||
$DIMDEC: BS
|
||||
$DIMTDEC: BS
|
||||
$DIMALTU: BS
|
||||
$DIMALTTD: BS
|
||||
$DIMAUNIT: BS
|
||||
$DIMFRAC: BS
|
||||
$DIMLUNIT: BS
|
||||
$DIMDSEP: BS
|
||||
$DIMTMOVE: BS
|
||||
$DIMJUST: BS
|
||||
$DIMSD1: B
|
||||
$DIMSD2: B
|
||||
$DIMTOLJ: BS
|
||||
$DIMTZIN: BS
|
||||
$DIMALTZ: BS
|
||||
$DIMALTTZ: BS
|
||||
$DIMUPT: B
|
||||
$DIMATFIT: BS
|
||||
|
||||
ver: R2007+
|
||||
$DIMFXLON: B
|
||||
|
||||
ver: R2010+
|
||||
$DIMTXTDIRECTION: B
|
||||
$DIMALTMZF: BD
|
||||
$DIMALTMZS: T
|
||||
$DIMMZF: BD
|
||||
$DIMMZS: T
|
||||
|
||||
ver: R2000+
|
||||
$DIMTXSTY: H # (hard pointer)
|
||||
$DIMLDRBLK: H # (hard pointer)
|
||||
$DIMBLK: H # (hard pointer)
|
||||
$DIMBLK1: H # (hard pointer)
|
||||
$DIMBLK2: H # (hard pointer)
|
||||
|
||||
ver: R2007+
|
||||
$DIMLTYPE: H # (hard pointer)
|
||||
$DIMLTEX1: H # (hard pointer)
|
||||
$DIMLTEX2: H # (hard pointer)
|
||||
|
||||
ver: R2000+
|
||||
$DIMLWD: BS
|
||||
$DIMLWE: BS
|
||||
|
||||
ver: all
|
||||
$BLOCK_CONTROL_OBJECT: H # (hard owner) Block Record Table
|
||||
$LAYER_CONTROL_OBJECT: H # (hard owner) Layer Table
|
||||
$STYLE_CONTROL_OBJECT: H # (hard owner) Style Table
|
||||
$LINETYPE_CONTROL_OBJECT: H # (hard owner) Linetype Table
|
||||
$VIEW_CONTROL_OBJECT: H # (hard owner) View table
|
||||
$UCS_CONTROL_OBJECT: H # (hard owner) UCS Table
|
||||
$VPORT_CONTROL_OBJECT: H # (hard owner) Viewport table
|
||||
$APPID_CONTROL_OBJECT: H # (hard owner) AppID Table
|
||||
$DIMSTYLE_CONTROL_OBJECT: H # (hard owner) Dimstyle Table
|
||||
|
||||
ver: R13-R2000
|
||||
$VIEWPORT_ENTITY_HEADER_CONTROL_OBJECT: H # (hard owner)
|
||||
|
||||
ver: all
|
||||
$ACAD_GROUP_DICTIONARY: H # (hard pointer)
|
||||
$ACAD_MLINESTYLE_DICTIONARY: H # (hard pointer)
|
||||
$ROOT_DICTIONARY: H # (NAMED OBJECTS) (hard owner)
|
||||
|
||||
ver: R2000+
|
||||
$TSTACKALIGN: BS # default = 1 (not present in DXF)
|
||||
$TSTACKSIZE: BS # default = 70 (not present in DXF)
|
||||
$HYPERLINKBASE: TV
|
||||
$STYLESHEET: TV
|
||||
$LAYOUTS_DICTIONARY: H # (hard pointer)
|
||||
$PLOTSETTINGS_DICTIONARY: H # (hard pointer)
|
||||
$PLOTSTYLES_DICTIONARY: H # (hard pointer)
|
||||
|
||||
ver: R2004+
|
||||
$MATERIALS_DICTIONARY: H # (hard pointer)
|
||||
$COLORS_DICTIONARY: H # (hard pointer)
|
||||
|
||||
ver: R2007+
|
||||
$VISUALSTYLE_DICTIONARY: H # (hard pointer)
|
||||
|
||||
ver: R2013+
|
||||
$UNKNOWN: H # (hard pointer)
|
||||
|
||||
ver: R2000+
|
||||
$R2000_PLUS_FLAGS: BL
|
||||
# CELWEIGHT Flags & 0x001F
|
||||
# ENDCAPS Flags & 0x0060
|
||||
# JOINSTYLE Flags & 0x0180
|
||||
# LWDISPLAY !(Flags & 0x0200)
|
||||
# XEDIT !(Flags & 0x0400)
|
||||
# EXTNAMES Flags & 0x0800
|
||||
# PSTYLEMODE Flags & 0x2000
|
||||
# OLESTARTUP Flags & 0x4000
|
||||
$INSUNITS: BS
|
||||
$CEPSNTYPE: BS
|
||||
|
||||
skip_next_if: header['$CEPSNTYPE'] != 3
|
||||
$CPSNID: H # (present only if CEPSNTYPE == 3) (hard pointer)
|
||||
|
||||
$FINGERPRINTGUID: TV
|
||||
$VERSIONGUID: TV
|
||||
|
||||
ver: R2004+
|
||||
$SORTENTS: RC
|
||||
$INDEXCTL: RC
|
||||
$HIDETEXT: RC
|
||||
$XCLIPFRAME: RC # before R2010 the value can be 0 or 1 only.
|
||||
$DIMASSOC: RC
|
||||
$HALOGAP: RC
|
||||
$OBSCUREDCOLOR: BS
|
||||
$INTERSECTIONCOLOR: BS
|
||||
$OBSCUREDLTYPE: RC
|
||||
$INTERSECTIONDISPLAY: RC
|
||||
$PROJECTNAME: TV
|
||||
|
||||
ver: all
|
||||
$PAPER_SPACE_BLOCK_RECORD: H # (hard pointer)
|
||||
$MODEL_SPACE_BLOCK_RECORD: H # (hard pointer)
|
||||
$BYLAYER_LTYPE: H # (hard pointer)
|
||||
$BYBLOCK_LTYPE: H # (hard pointer)
|
||||
$CONTINUOUS_LTYPE: H # (hard pointer)
|
||||
|
||||
ver: R2007+
|
||||
$CAMERADISPLAY: B
|
||||
$UNKNOWN: BL
|
||||
$UNKNOWN: BL
|
||||
$UNKNOWN: BD
|
||||
$STEPSPERSEC: BD
|
||||
$STEPSIZE: BD
|
||||
$3DDWFPREC: BD
|
||||
$LENSLENGTH: BD
|
||||
$CAMERAHEIGHT: BD
|
||||
$SOLIDHIST: RC
|
||||
$SHOWHIST: RC
|
||||
$PSOLWIDTH: BD
|
||||
$PSOLHEIGHT: BD
|
||||
$LOFTANG1: BD
|
||||
$LOFTANG2: BD
|
||||
$LOFTMAG1: BD
|
||||
$LOFTMAG2: BD
|
||||
$LOFTPARAM: BS
|
||||
$LOFTNORMALS: RC
|
||||
$LATITUDE: BD
|
||||
$LONGITUDE: BD
|
||||
$NORTHDIRECTION: BD
|
||||
$TIMEZONE: BL
|
||||
$LIGHTGLYPHDISPLAY: RC
|
||||
$TILEMODELIGHTSYNCH: RC
|
||||
$DWFFRAME: RC
|
||||
$DGNFRAME: RC
|
||||
$UNKNOWN: B
|
||||
$INTERFERECOLOR: CMC
|
||||
$INTERFEREOBJVS: H # (hard pointer)
|
||||
$INTERFEREVPVS: H # (hard pointer)
|
||||
$CSHADOW: RC
|
||||
$UNKNOWN: BD
|
||||
|
||||
ver: R14+
|
||||
$UNKNOWN: BS # short (type 5/6 only) these do not seem to be required,
|
||||
$UNKNOWN: BS # short (type 5/6 only) even for type 5.
|
||||
$UNKNOWN: BS # short (type 5/6 only)
|
||||
$UNKNOWN: BS # short (type 5/6 only)
|
||||
|
||||
"""
|
||||
@@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2020-2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from typing import Dict
|
||||
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.tools import codepage
|
||||
|
||||
from ezdxf.sections.header import HeaderSection
|
||||
from ezdxf.sections.classes import ClassesSection
|
||||
from ezdxf.sections.tables import TablesSection
|
||||
from ezdxf.sections.blocks import BlocksSection
|
||||
from ezdxf.sections.entities import EntitySection
|
||||
from ezdxf.sections.objects import ObjectsSection
|
||||
from ezdxf.sections.acdsdata import AcDsDataSection
|
||||
|
||||
from .const import *
|
||||
from .fileheader import FileHeader
|
||||
from .header_section import load_header_section
|
||||
from .classes_section import load_classes_section
|
||||
|
||||
__all__ = ["readfile", "load"]
|
||||
|
||||
|
||||
def readfile(filename: str, crc_check=False) -> "Drawing":
|
||||
data = open(filename, "rb").read()
|
||||
return load(data, crc_check)
|
||||
|
||||
|
||||
def load(data: bytes, crc_check=False) -> Drawing:
|
||||
doc = DwgDocument(data, crc_check=crc_check)
|
||||
doc.load()
|
||||
return doc.doc
|
||||
|
||||
|
||||
class DwgDocument:
|
||||
def __init__(self, data: Bytes, crc_check=False):
|
||||
self.data = memoryview(data)
|
||||
self.crc_check = crc_check
|
||||
self.specs = FileHeader(data, crc_check=crc_check)
|
||||
self.doc: Drawing = self._setup_doc()
|
||||
# Store DXF object types by class number:
|
||||
self.dxf_object_types: Dict[int, str] = dict()
|
||||
|
||||
def _setup_doc(self) -> Drawing:
|
||||
doc = Drawing(dxfversion=self.specs.version)
|
||||
doc.encoding = self.specs.encoding
|
||||
doc.header = HeaderSection.new()
|
||||
|
||||
# Setup basic header variables not stored in the header section of the DWG file.
|
||||
doc.header["$ACADVER"] = self.specs.version
|
||||
doc.header["$ACADMAINTVER"] = self.specs.maintenance_release_version
|
||||
doc.header["$DWGCODEPAGE"] = codepage.tocodepage(self.specs.encoding)
|
||||
|
||||
doc.classes = ClassesSection(doc)
|
||||
# doc.tables = TablesSection(doc)
|
||||
# doc.blocks = BlocksSection(doc)
|
||||
# doc.entities = EntitySection(doc)
|
||||
# doc.objects = ObjectsSection(doc)
|
||||
# doc.acdsdata = AcDsDataSection(doc)
|
||||
return doc
|
||||
|
||||
def load(self):
|
||||
self.load_header()
|
||||
self.load_classes()
|
||||
self.load_objects()
|
||||
self.store_objects()
|
||||
|
||||
def load_header(self) -> None:
|
||||
hdr_section = load_header_section(self.specs, self.data, self.crc_check)
|
||||
hdr_vars = hdr_section.load_header_vars()
|
||||
self.set_header_vars(hdr_vars)
|
||||
|
||||
def set_header_vars(self, hdr_vars: Dict):
|
||||
pass
|
||||
|
||||
def load_classes(self) -> None:
|
||||
cls_section = load_classes_section(
|
||||
self.specs, self.data, self.crc_check
|
||||
)
|
||||
for class_num, dxfclass in cls_section.load_classes():
|
||||
self.doc.classes.register(dxfclass)
|
||||
self.dxf_object_types[class_num] = dxfclass.dxf.name
|
||||
|
||||
def load_objects(self) -> None:
|
||||
pass
|
||||
|
||||
def store_objects(self) -> None:
|
||||
pass
|
||||
@@ -0,0 +1,904 @@
|
||||
# Copyright (c) 2019-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Iterable, Mapping, Optional
|
||||
import json
|
||||
|
||||
from ezdxf.sections.tables import TABLENAMES
|
||||
from ezdxf.lldxf.tags import Tags
|
||||
from ezdxf.entities import BoundaryPathType, EdgeType
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.lldxf.types import DXFTag
|
||||
from ezdxf.entities import (
|
||||
Insert,
|
||||
MText,
|
||||
LWPolyline,
|
||||
Polyline,
|
||||
Spline,
|
||||
Leader,
|
||||
Dimension,
|
||||
Image,
|
||||
Mesh,
|
||||
Hatch,
|
||||
MPolygon,
|
||||
Wipeout,
|
||||
)
|
||||
from ezdxf.entities import DXFEntity, Linetype
|
||||
from ezdxf.entities.polygon import DXFPolygon
|
||||
from ezdxf.layouts import BlockLayout
|
||||
|
||||
__all__ = [
|
||||
"entities_to_code",
|
||||
"block_to_code",
|
||||
"table_entries_to_code",
|
||||
"black",
|
||||
]
|
||||
|
||||
|
||||
def black(code: str, line_length=88, fast: bool = True) -> str:
|
||||
"""Returns the source `code` as a single string formatted by `Black`_
|
||||
|
||||
Requires the installed `Black`_ formatter::
|
||||
|
||||
pip3 install black
|
||||
|
||||
Args:
|
||||
code: source code
|
||||
line_length: max. source code line length
|
||||
fast: ``True`` for fast mode, ``False`` to check that the reformatted
|
||||
code is valid
|
||||
|
||||
Raises:
|
||||
ImportError: Black is not available
|
||||
|
||||
.. _black: https://pypi.org/project/black/
|
||||
|
||||
"""
|
||||
|
||||
import black
|
||||
|
||||
mode = black.FileMode()
|
||||
mode.line_length = line_length
|
||||
return black.format_file_contents(code, fast=fast, mode=mode)
|
||||
|
||||
|
||||
def entities_to_code(
|
||||
entities: Iterable[DXFEntity],
|
||||
layout: str = "layout",
|
||||
ignore: Optional[Iterable[str]] = None,
|
||||
) -> Code:
|
||||
"""
|
||||
Translates DXF entities into Python source code to recreate this entities
|
||||
by ezdxf.
|
||||
|
||||
Args:
|
||||
entities: iterable of DXFEntity
|
||||
layout: variable name of the layout (model space or block) as string
|
||||
ignore: iterable of entities types to ignore as strings
|
||||
like ``['IMAGE', 'DIMENSION']``
|
||||
|
||||
Returns:
|
||||
:class:`Code`
|
||||
|
||||
"""
|
||||
code = _SourceCodeGenerator(layout=layout)
|
||||
code.translate_entities(entities, ignore=ignore)
|
||||
return code.code
|
||||
|
||||
|
||||
def block_to_code(
|
||||
block: BlockLayout,
|
||||
drawing: str = "doc",
|
||||
ignore: Optional[Iterable[str]] = None,
|
||||
) -> Code:
|
||||
"""
|
||||
Translates a BLOCK into Python source code to recreate the BLOCK by ezdxf.
|
||||
|
||||
Args:
|
||||
block: block definition layout
|
||||
drawing: variable name of the drawing as string
|
||||
ignore: iterable of entities types to ignore as strings
|
||||
like ['IMAGE', 'DIMENSION']
|
||||
|
||||
Returns:
|
||||
:class:`Code`
|
||||
|
||||
"""
|
||||
assert block.block is not None
|
||||
dxfattribs = _purge_handles(block.block.dxfattribs())
|
||||
block_name = dxfattribs.pop("name")
|
||||
base_point = dxfattribs.pop("base_point")
|
||||
code = _SourceCodeGenerator(layout="b")
|
||||
prolog = f'b = {drawing}.blocks.new("{block_name}", base_point={base_point}, dxfattribs={{'
|
||||
code.add_source_code_line(prolog)
|
||||
code.add_source_code_lines(_fmt_mapping(dxfattribs, indent=4))
|
||||
code.add_source_code_line(" }")
|
||||
code.add_source_code_line(")")
|
||||
code.translate_entities(block, ignore=ignore)
|
||||
return code.code
|
||||
|
||||
|
||||
def table_entries_to_code(
|
||||
entities: Iterable[DXFEntity], drawing="doc"
|
||||
) -> Code:
|
||||
code = _SourceCodeGenerator(doc=drawing)
|
||||
code.translate_entities(entities)
|
||||
return code.code
|
||||
|
||||
|
||||
class Code:
|
||||
"""Source code container."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.code: list[str] = []
|
||||
# global imports -> indentation level 0:
|
||||
self.imports: set[str] = set()
|
||||
# layer names as string:
|
||||
self.layers: set[str] = set()
|
||||
# text style name as string, requires a TABLE entry:
|
||||
self.styles: set[str] = set()
|
||||
# line type names as string, requires a TABLE entry:
|
||||
self.linetypes: set[str] = set()
|
||||
# dimension style names as string, requires a TABLE entry:
|
||||
self.dimstyles: set[str] = set()
|
||||
# block names as string, requires a BLOCK definition:
|
||||
self.blocks: set[str] = set()
|
||||
|
||||
def code_str(self, indent: int = 0) -> str:
|
||||
"""Returns the source code as a single string.
|
||||
|
||||
Args:
|
||||
indent: source code indentation count by spaces
|
||||
|
||||
"""
|
||||
lead_str = " " * indent
|
||||
return "\n".join(lead_str + line for line in self.code)
|
||||
|
||||
def black_code_str(self, line_length=88) -> str:
|
||||
"""Returns the source code as a single string formatted by `Black`_
|
||||
|
||||
Args:
|
||||
line_length: max. source code line length
|
||||
|
||||
Raises:
|
||||
ImportError: Black is not available
|
||||
|
||||
"""
|
||||
return black(self.code_str(), line_length)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the source code as a single string."""
|
||||
|
||||
return self.code_str()
|
||||
|
||||
def import_str(self, indent: int = 0) -> str:
|
||||
"""Returns required imports as a single string.
|
||||
|
||||
Args:
|
||||
indent: source code indentation count by spaces
|
||||
|
||||
"""
|
||||
lead_str = " " * indent
|
||||
return "\n".join(lead_str + line for line in self.imports)
|
||||
|
||||
def add_import(self, statement: str) -> None:
|
||||
"""Add import statement, identical import statements are merged
|
||||
together.
|
||||
"""
|
||||
self.imports.add(statement)
|
||||
|
||||
def add_line(self, code: str, indent: int = 0) -> None:
|
||||
"""Add a single source code line without line ending ``\\n``."""
|
||||
self.code.append(" " * indent + code)
|
||||
|
||||
def add_lines(self, code: Iterable[str], indent: int = 0) -> None:
|
||||
"""Add multiple source code lines without line ending ``\\n``."""
|
||||
for line in code:
|
||||
self.add_line(line, indent=indent)
|
||||
|
||||
def merge(self, code: Code, indent: int = 0) -> None:
|
||||
"""Add another :class:`Code` object."""
|
||||
# merge used resources
|
||||
self.imports.update(code.imports)
|
||||
self.layers.update(code.layers)
|
||||
self.linetypes.update(code.linetypes)
|
||||
self.styles.update(code.styles)
|
||||
self.dimstyles.update(code.dimstyles)
|
||||
self.blocks.update(code.blocks)
|
||||
|
||||
# append source code lines
|
||||
self.add_lines(code.code, indent=indent)
|
||||
|
||||
|
||||
_PURGE_DXF_ATTRIBUTES = {
|
||||
"handle",
|
||||
"owner",
|
||||
"paperspace",
|
||||
"material_handle",
|
||||
"visualstyle_handle",
|
||||
"plotstyle_handle",
|
||||
}
|
||||
|
||||
|
||||
def _purge_handles(attribs: dict) -> dict:
|
||||
"""Purge handles from DXF attributes which will be invalid in a new
|
||||
document, or which will be set automatically by adding an entity to a
|
||||
layout (paperspace).
|
||||
|
||||
Args:
|
||||
attribs: entity DXF attributes dictionary
|
||||
|
||||
"""
|
||||
return {k: v for k, v in attribs.items() if k not in _PURGE_DXF_ATTRIBUTES}
|
||||
|
||||
|
||||
def _fmt_mapping(mapping: Mapping, indent: int = 0) -> Iterable[str]:
|
||||
# key is always a string
|
||||
fmt = " " * indent + "'{}': {},"
|
||||
for k, v in mapping.items():
|
||||
assert isinstance(k, str)
|
||||
if isinstance(v, str):
|
||||
v = json.dumps(v) # for correct escaping of quotes
|
||||
else:
|
||||
v = str(v) # format uses repr() for Vec3s
|
||||
yield fmt.format(k, v)
|
||||
|
||||
|
||||
def _fmt_list(l: Iterable, indent: int = 0) -> Iterable[str]:
|
||||
def cleanup(values: Iterable) -> Iterable:
|
||||
for value in values:
|
||||
if isinstance(value, np.float64):
|
||||
yield float(value)
|
||||
else:
|
||||
yield value
|
||||
|
||||
fmt = " " * indent + "{},"
|
||||
for v in l:
|
||||
if not isinstance(v, (float, int, str)):
|
||||
v = tuple(cleanup(v))
|
||||
yield fmt.format(str(v))
|
||||
|
||||
|
||||
def _fmt_api_call(
|
||||
func_call: str, args: Iterable[str], dxfattribs: dict
|
||||
) -> list[str]:
|
||||
attributes = dict(dxfattribs)
|
||||
args = list(args) if args else []
|
||||
|
||||
def fmt_keywords() -> Iterable[str]:
|
||||
for arg in args:
|
||||
if arg not in attributes:
|
||||
continue
|
||||
value = attributes.pop(arg)
|
||||
if isinstance(value, str):
|
||||
valuestr = json.dumps(value) # quoted string!
|
||||
else:
|
||||
valuestr = str(value)
|
||||
yield " {}={},".format(arg, valuestr)
|
||||
|
||||
s = [func_call]
|
||||
s.extend(fmt_keywords())
|
||||
s.append(" dxfattribs={")
|
||||
s.extend(_fmt_mapping(attributes, indent=8))
|
||||
s.extend(
|
||||
[
|
||||
" },",
|
||||
")",
|
||||
]
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def _fmt_dxf_tags(tags: Iterable[DXFTag], indent: int = 0):
|
||||
fmt = " " * indent + "dxftag({}, {}),"
|
||||
for code, value in tags:
|
||||
assert isinstance(code, int)
|
||||
if isinstance(value, str):
|
||||
value = json.dumps(value) # for correct escaping of quotes
|
||||
else:
|
||||
value = str(value) # format uses repr() for Vec3s
|
||||
yield fmt.format(code, value)
|
||||
|
||||
|
||||
class _SourceCodeGenerator:
|
||||
"""
|
||||
The :class:`_SourceCodeGenerator` translates DXF entities into Python source
|
||||
code for creating the same DXF entity in another model space or block
|
||||
definition.
|
||||
|
||||
:ivar code: list of source code lines without line endings
|
||||
:ivar required_imports: list of import source code lines, which are required
|
||||
to create executable Python code.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, layout: str = "layout", doc: str = "doc"):
|
||||
self.doc = doc
|
||||
self.layout = layout
|
||||
self.code = Code()
|
||||
|
||||
def translate_entity(self, entity: DXFEntity) -> None:
|
||||
"""Translates one DXF entity into Python source code. The generated
|
||||
source code is appended to the attribute `source_code`.
|
||||
|
||||
Args:
|
||||
entity: DXFEntity object
|
||||
|
||||
"""
|
||||
dxftype = entity.dxftype()
|
||||
try:
|
||||
entity_translator = getattr(self, "_" + dxftype.lower())
|
||||
except AttributeError:
|
||||
self.add_source_code_line(f'# unsupported DXF entity "{dxftype}"')
|
||||
else:
|
||||
entity_translator(entity)
|
||||
|
||||
def translate_entities(
|
||||
self,
|
||||
entities: Iterable[DXFEntity],
|
||||
ignore: Optional[Iterable[str]] = None,
|
||||
) -> None:
|
||||
"""Translates multiple DXF entities into Python source code. The
|
||||
generated source code is appended to the attribute `source_code`.
|
||||
|
||||
Args:
|
||||
entities: iterable of DXFEntity
|
||||
ignore: iterable of entities types to ignore as strings
|
||||
like ['IMAGE', 'DIMENSION']
|
||||
|
||||
"""
|
||||
ignore = set(ignore) if ignore else set()
|
||||
|
||||
for entity in entities:
|
||||
if entity.dxftype() not in ignore:
|
||||
self.translate_entity(entity)
|
||||
|
||||
def add_used_resources(self, dxfattribs: Mapping) -> None:
|
||||
"""Register used resources like layers, line types, text styles and
|
||||
dimension styles.
|
||||
|
||||
Args:
|
||||
dxfattribs: DXF attributes dictionary
|
||||
|
||||
"""
|
||||
if "layer" in dxfattribs:
|
||||
self.code.layers.add(dxfattribs["layer"])
|
||||
if "linetype" in dxfattribs:
|
||||
self.code.linetypes.add(dxfattribs["linetype"])
|
||||
if "style" in dxfattribs:
|
||||
self.code.styles.add(dxfattribs["style"])
|
||||
if "dimstyle" in dxfattribs:
|
||||
self.code.dimstyles.add(dxfattribs["dimstyle"])
|
||||
|
||||
def add_import_statement(self, statement: str) -> None:
|
||||
self.code.add_import(statement)
|
||||
|
||||
def add_source_code_line(self, code: str) -> None:
|
||||
self.code.add_line(code)
|
||||
|
||||
def add_source_code_lines(self, code: Iterable[str]) -> None:
|
||||
assert not isinstance(code, str)
|
||||
self.code.add_lines(code)
|
||||
|
||||
def add_list_source_code(
|
||||
self,
|
||||
values: Iterable,
|
||||
prolog: str = "[",
|
||||
epilog: str = "]",
|
||||
indent: int = 0,
|
||||
) -> None:
|
||||
fmt_str = " " * indent + "{}"
|
||||
self.add_source_code_line(fmt_str.format(prolog))
|
||||
self.add_source_code_lines(_fmt_list(values, indent=4 + indent))
|
||||
self.add_source_code_line(fmt_str.format(epilog))
|
||||
|
||||
def add_dict_source_code(
|
||||
self,
|
||||
mapping: Mapping,
|
||||
prolog: str = "{",
|
||||
epilog: str = "}",
|
||||
indent: int = 0,
|
||||
) -> None:
|
||||
fmt_str = " " * indent + "{}"
|
||||
self.add_source_code_line(fmt_str.format(prolog))
|
||||
self.add_source_code_lines(_fmt_mapping(mapping, indent=4 + indent))
|
||||
self.add_source_code_line(fmt_str.format(epilog))
|
||||
|
||||
def add_tags_source_code(
|
||||
self, tags: Tags, prolog="tags = Tags(", epilog=")", indent=4
|
||||
):
|
||||
fmt_str = " " * indent + "{}"
|
||||
self.add_source_code_line(fmt_str.format(prolog))
|
||||
self.add_source_code_lines(_fmt_dxf_tags(tags, indent=4 + indent))
|
||||
self.add_source_code_line(fmt_str.format(epilog))
|
||||
|
||||
def generic_api_call(
|
||||
self, dxftype: str, dxfattribs: dict, prefix: str = "e = "
|
||||
) -> Iterable[str]:
|
||||
"""Returns the source code strings to create a DXF entity by a generic
|
||||
`new_entity()` call.
|
||||
|
||||
Args:
|
||||
dxftype: DXF entity type as string, like 'LINE'
|
||||
dxfattribs: DXF attributes dictionary
|
||||
prefix: prefix string like variable assignment 'e = '
|
||||
|
||||
"""
|
||||
dxfattribs = _purge_handles(dxfattribs)
|
||||
self.add_used_resources(dxfattribs)
|
||||
s = [
|
||||
f"{prefix}{self.layout}.new_entity(",
|
||||
f" '{dxftype}',",
|
||||
" dxfattribs={",
|
||||
]
|
||||
s.extend(_fmt_mapping(dxfattribs, indent=8))
|
||||
s.extend(
|
||||
[
|
||||
" },",
|
||||
")",
|
||||
]
|
||||
)
|
||||
return s
|
||||
|
||||
def api_call(
|
||||
self,
|
||||
api_call: str,
|
||||
args: Iterable[str],
|
||||
dxfattribs: dict,
|
||||
prefix: str = "e = ",
|
||||
) -> Iterable[str]:
|
||||
"""Returns the source code strings to create a DXF entity by the
|
||||
specialised API call.
|
||||
|
||||
Args:
|
||||
api_call: API function call like 'add_line('
|
||||
args: DXF attributes to pass as arguments
|
||||
dxfattribs: DXF attributes dictionary
|
||||
prefix: prefix string like variable assignment 'e = '
|
||||
|
||||
"""
|
||||
dxfattribs = _purge_handles(dxfattribs)
|
||||
func_call = f"{prefix}{self.layout}.{api_call}"
|
||||
return _fmt_api_call(func_call, args, dxfattribs)
|
||||
|
||||
def new_table_entry(self, dxftype: str, dxfattribs: dict) -> Iterable[str]:
|
||||
"""Returns the source code strings to create a new table entity by
|
||||
ezdxf.
|
||||
|
||||
Args:
|
||||
dxftype: table entry type as string, like 'LAYER'
|
||||
dxfattribs: DXF attributes dictionary
|
||||
|
||||
"""
|
||||
table = f"{self.doc}.{TABLENAMES[dxftype]}"
|
||||
dxfattribs = _purge_handles(dxfattribs)
|
||||
name = dxfattribs.pop("name")
|
||||
s = [
|
||||
f"if '{name}' not in {table}:",
|
||||
f" t = {table}.new(",
|
||||
f" '{name}',",
|
||||
" dxfattribs={",
|
||||
]
|
||||
s.extend(_fmt_mapping(dxfattribs, indent=12))
|
||||
s.extend(
|
||||
[
|
||||
" },",
|
||||
" )",
|
||||
]
|
||||
)
|
||||
return s
|
||||
|
||||
# simple graphical types
|
||||
|
||||
def _line(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.api_call("add_line(", ["start", "end"], entity.dxfattribs())
|
||||
)
|
||||
|
||||
def _point(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.api_call("add_point(", ["location"], entity.dxfattribs())
|
||||
)
|
||||
|
||||
def _circle(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.api_call(
|
||||
"add_circle(", ["center", "radius"], entity.dxfattribs()
|
||||
)
|
||||
)
|
||||
|
||||
def _arc(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.api_call(
|
||||
"add_arc(",
|
||||
["center", "radius", "start_angle", "end_angle"],
|
||||
entity.dxfattribs(),
|
||||
)
|
||||
)
|
||||
|
||||
def _text(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.api_call("add_text(", ["text"], entity.dxfattribs())
|
||||
)
|
||||
|
||||
def _solid(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("SOLID", entity.dxfattribs())
|
||||
)
|
||||
|
||||
def _trace(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("TRACE", entity.dxfattribs())
|
||||
)
|
||||
|
||||
def _3dface(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("3DFACE", entity.dxfattribs())
|
||||
)
|
||||
|
||||
def _shape(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.api_call(
|
||||
"add_shape(", ["name", "insert", "size"], entity.dxfattribs()
|
||||
)
|
||||
)
|
||||
|
||||
def _attrib(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.api_call(
|
||||
"add_attrib(", ["tag", "text", "insert"], entity.dxfattribs()
|
||||
)
|
||||
)
|
||||
|
||||
def _attdef(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("ATTDEF", entity.dxfattribs())
|
||||
)
|
||||
|
||||
def _ellipse(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.api_call(
|
||||
"add_ellipse(",
|
||||
["center", "major_axis", "ratio", "start_param", "end_param"],
|
||||
entity.dxfattribs(),
|
||||
)
|
||||
)
|
||||
|
||||
def _viewport(self, entity: DXFEntity) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("VIEWPORT", entity.dxfattribs())
|
||||
)
|
||||
self.add_source_code_line(
|
||||
'# Set valid handles or remove attributes ending with "_handle", '
|
||||
"otherwise the DXF file is invalid for AutoCAD"
|
||||
)
|
||||
|
||||
# complex graphical types
|
||||
|
||||
def _insert(self, entity: Insert) -> None:
|
||||
self.code.blocks.add(entity.dxf.name)
|
||||
self.add_source_code_lines(
|
||||
self.api_call(
|
||||
"add_blockref(", ["name", "insert"], entity.dxfattribs()
|
||||
)
|
||||
)
|
||||
if len(entity.attribs):
|
||||
for attrib in entity.attribs:
|
||||
dxfattribs = attrib.dxfattribs()
|
||||
dxfattribs[
|
||||
"layer"
|
||||
] = entity.dxf.layer # set ATTRIB layer to same as INSERT
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call(
|
||||
"ATTRIB", attrib.dxfattribs(), prefix="a = "
|
||||
)
|
||||
)
|
||||
self.add_source_code_line("e.attribs.append(a)")
|
||||
|
||||
def _mtext(self, entity: MText) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("MTEXT", entity.dxfattribs())
|
||||
)
|
||||
# MTEXT content 'text' is not a single DXF tag and therefore not a DXF
|
||||
# attribute
|
||||
self.add_source_code_line("e.text = {}".format(json.dumps(entity.text)))
|
||||
|
||||
def _lwpolyline(self, entity: LWPolyline) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("LWPOLYLINE", entity.dxfattribs())
|
||||
)
|
||||
# lwpolyline points are not DXF attributes
|
||||
self.add_list_source_code(
|
||||
entity.get_points(), prolog="e.set_points([", epilog="])"
|
||||
)
|
||||
|
||||
def _spline(self, entity: Spline) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.api_call("add_spline(", ["degree"], entity.dxfattribs())
|
||||
)
|
||||
# spline points, knots and weights are not DXF attributes
|
||||
if len(entity.fit_points):
|
||||
self.add_list_source_code(
|
||||
entity.fit_points, prolog="e.fit_points = [", epilog="]"
|
||||
)
|
||||
|
||||
if len(entity.control_points):
|
||||
self.add_list_source_code(
|
||||
entity.control_points, prolog="e.control_points = [", epilog="]"
|
||||
)
|
||||
|
||||
if len(entity.knots):
|
||||
self.add_list_source_code(
|
||||
entity.knots, prolog="e.knots = [", epilog="]"
|
||||
)
|
||||
|
||||
if len(entity.weights):
|
||||
self.add_list_source_code(
|
||||
entity.weights, prolog="e.weights = [", epilog="]"
|
||||
)
|
||||
|
||||
def _polyline(self, entity: Polyline) -> None:
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("POLYLINE", entity.dxfattribs())
|
||||
)
|
||||
# polyline vertices are separate DXF entities and therefore not DXF attributes
|
||||
for v in entity.vertices:
|
||||
attribs = _purge_handles(v.dxfattribs())
|
||||
location = attribs.pop("location")
|
||||
if "layer" in attribs:
|
||||
del attribs[
|
||||
"layer"
|
||||
] # layer is automatically set to the POLYLINE layer
|
||||
|
||||
# each VERTEX can have different DXF attributes: bulge, start_width, end_width ...
|
||||
self.add_source_code_line(
|
||||
f"e.append_vertex({str(location)}, dxfattribs={attribs})"
|
||||
)
|
||||
|
||||
def _leader(self, entity: Leader):
|
||||
self.add_source_code_line(
|
||||
"# Dimension style attribute overriding is not supported!"
|
||||
)
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("LEADER", entity.dxfattribs())
|
||||
)
|
||||
self.add_list_source_code(
|
||||
entity.vertices, prolog="e.set_vertices([", epilog="])"
|
||||
)
|
||||
|
||||
def _dimension(self, entity: Dimension):
|
||||
self.add_import_statement(
|
||||
"from ezdxf.dimstyleoverride import DimStyleOverride"
|
||||
)
|
||||
self.add_source_code_line(
|
||||
"# Dimension style attribute overriding is not supported!"
|
||||
)
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("DIMENSION", entity.dxfattribs())
|
||||
)
|
||||
self.add_source_code_lines(
|
||||
[
|
||||
"# You have to create the required graphical representation for ",
|
||||
"# the DIMENSION entity as anonymous block, otherwise the DXF file",
|
||||
"# is invalid for AutoCAD (but not for BricsCAD):",
|
||||
"# DimStyleOverride(e).render()",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
def _image(self, entity: Image):
|
||||
self.add_source_code_line(
|
||||
"# Image requires IMAGEDEF and IMAGEDEFREACTOR objects in the "
|
||||
"OBJECTS section!"
|
||||
)
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("IMAGE", entity.dxfattribs())
|
||||
)
|
||||
if len(entity.boundary_path):
|
||||
self.add_list_source_code(
|
||||
(v for v in entity.boundary_path), # just x, y axis
|
||||
prolog="e.set_boundary_path([",
|
||||
epilog="])",
|
||||
)
|
||||
self.add_source_code_line(
|
||||
"# Set valid image_def_handle and image_def_reactor_handle, "
|
||||
"otherwise the DXF file is invalid for AutoCAD"
|
||||
)
|
||||
|
||||
def _wipeout(self, entity: Wipeout):
|
||||
self.add_source_code_lines(
|
||||
self.generic_api_call("WIPEOUT", entity.dxfattribs())
|
||||
)
|
||||
if len(entity.boundary_path):
|
||||
self.add_list_source_code(
|
||||
(v for v in entity.boundary_path), # just x, y axis
|
||||
prolog="e.set_boundary_path([",
|
||||
epilog="])",
|
||||
)
|
||||
|
||||
def _mesh(self, entity: Mesh):
|
||||
self.add_source_code_lines(
|
||||
self.api_call("add_mesh(", [], entity.dxfattribs())
|
||||
)
|
||||
if len(entity.vertices):
|
||||
self.add_list_source_code(
|
||||
entity.vertices, prolog="e.vertices = [", epilog="]"
|
||||
)
|
||||
if len(entity.edges):
|
||||
# array.array -> tuple
|
||||
self.add_list_source_code(
|
||||
(tuple(e) for e in entity.edges),
|
||||
prolog="e.edges = [",
|
||||
epilog="]",
|
||||
)
|
||||
if len(entity.faces):
|
||||
# array.array -> tuple
|
||||
self.add_list_source_code(
|
||||
(tuple(f) for f in entity.faces),
|
||||
prolog="e.faces = [",
|
||||
epilog="]",
|
||||
)
|
||||
if len(entity.creases):
|
||||
self.add_list_source_code(
|
||||
entity.creases, prolog="e.creases = [", epilog="]"
|
||||
)
|
||||
|
||||
def _hatch(self, entity: Hatch):
|
||||
dxfattribs = entity.dxfattribs()
|
||||
dxfattribs["associative"] = 0 # associative hatch not supported
|
||||
self.add_source_code_lines(
|
||||
self.api_call("add_hatch(", ["color"], dxfattribs)
|
||||
)
|
||||
self._polygon(entity)
|
||||
|
||||
def _mpolygon(self, entity: MPolygon):
|
||||
dxfattribs = entity.dxfattribs()
|
||||
self.add_source_code_lines(
|
||||
self.api_call("add_mpolygon(", ["color"], dxfattribs)
|
||||
)
|
||||
if entity.dxf.solid_fill:
|
||||
self.add_source_code_line(
|
||||
f"e.set_solid_fill(color={entity.dxf.fill_color})\n"
|
||||
)
|
||||
self._polygon(entity)
|
||||
|
||||
def _polygon(self, entity: DXFPolygon):
|
||||
add_line = self.add_source_code_line
|
||||
if len(entity.seeds):
|
||||
add_line(f"e.set_seed_points({entity.seeds})")
|
||||
if entity.pattern:
|
||||
self.add_list_source_code(
|
||||
map(str, entity.pattern.lines),
|
||||
prolog="e.set_pattern_definition([",
|
||||
epilog="])",
|
||||
)
|
||||
self.add_source_code_line("e.dxf.solid_fill = 0")
|
||||
arg = " {}={},"
|
||||
|
||||
if entity.gradient is not None:
|
||||
g = entity.gradient
|
||||
add_line("e.set_gradient(")
|
||||
add_line(arg.format("color1", str(g.color1)))
|
||||
add_line(arg.format("color2", str(g.color2)))
|
||||
add_line(arg.format("rotation", g.rotation))
|
||||
add_line(arg.format("centered", g.centered))
|
||||
add_line(arg.format("one_color", g.one_color))
|
||||
add_line(arg.format("name", json.dumps(g.name)))
|
||||
add_line(")")
|
||||
for count, path in enumerate(entity.paths, start=1):
|
||||
if path.type == BoundaryPathType.POLYLINE:
|
||||
add_line("# {}. polyline path".format(count))
|
||||
self.add_list_source_code(
|
||||
path.vertices,
|
||||
prolog="e.paths.add_polyline_path([",
|
||||
epilog=" ],",
|
||||
)
|
||||
add_line(arg.format("is_closed", str(path.is_closed)))
|
||||
add_line(arg.format("flags", str(path.path_type_flags)))
|
||||
add_line(")")
|
||||
else: # EdgePath
|
||||
add_line(
|
||||
f"# {count}. edge path: associative hatch not supported"
|
||||
)
|
||||
add_line(
|
||||
f"ep = e.paths.add_edge_path(flags={path.path_type_flags})"
|
||||
)
|
||||
for edge in path.edges:
|
||||
if edge.type == EdgeType.LINE:
|
||||
add_line(f"ep.add_line({edge.start}, {str(edge.end)})")
|
||||
elif edge.type == EdgeType.ARC:
|
||||
# Start- and end angles are always stored in ccw
|
||||
# orientation:
|
||||
add_line("ep.add_arc(")
|
||||
add_line(arg.format("center", str(edge.center)))
|
||||
add_line(arg.format("radius", edge.radius))
|
||||
add_line(arg.format("start_angle", edge.start_angle))
|
||||
add_line(arg.format("end_angle", edge.end_angle))
|
||||
add_line(arg.format("ccw", edge.ccw))
|
||||
add_line(")")
|
||||
elif edge.type == EdgeType.ELLIPSE:
|
||||
# Start- and end params are always stored in ccw
|
||||
# orientation:
|
||||
add_line("ep.add_ellipse(")
|
||||
add_line(arg.format("center", str(edge.center)))
|
||||
add_line(arg.format("major_axis", str(edge.major_axis)))
|
||||
add_line(arg.format("ratio", edge.ratio))
|
||||
add_line(arg.format("start_angle", edge.start_angle))
|
||||
add_line(arg.format("end_angle", edge.end_angle))
|
||||
add_line(arg.format("ccw", edge.ccw))
|
||||
add_line(")")
|
||||
elif edge.type == EdgeType.SPLINE:
|
||||
add_line("ep.add_spline(")
|
||||
if edge.fit_points:
|
||||
add_line(
|
||||
arg.format(
|
||||
"fit_points",
|
||||
str([fp for fp in edge.fit_points]),
|
||||
)
|
||||
)
|
||||
if edge.control_points:
|
||||
add_line(
|
||||
arg.format(
|
||||
"control_points",
|
||||
str([cp for cp in edge.control_points]),
|
||||
)
|
||||
)
|
||||
if edge.knot_values:
|
||||
add_line(
|
||||
arg.format("knot_values", str(edge.knot_values))
|
||||
)
|
||||
if edge.weights:
|
||||
add_line(arg.format("weights", str(edge.weights)))
|
||||
add_line(arg.format("degree", edge.degree))
|
||||
add_line(arg.format("periodic", edge.periodic))
|
||||
if edge.start_tangent is not None:
|
||||
add_line(
|
||||
arg.format(
|
||||
"start_tangent", str(edge.start_tangent)
|
||||
)
|
||||
)
|
||||
if edge.end_tangent is not None:
|
||||
add_line(
|
||||
arg.format("end_tangent", str(edge.end_tangent))
|
||||
)
|
||||
add_line(")")
|
||||
|
||||
# simple table entries
|
||||
def _layer(self, layer: DXFEntity):
|
||||
self.add_source_code_lines(
|
||||
self.new_table_entry("LAYER", layer.dxfattribs())
|
||||
)
|
||||
|
||||
def _ltype(self, ltype: Linetype):
|
||||
self.add_import_statement("from ezdxf.lldxf.tags import Tags")
|
||||
self.add_import_statement("from ezdxf.lldxf.types import dxftag")
|
||||
self.add_import_statement(
|
||||
"from ezdxf.entities.ltype import LinetypePattern"
|
||||
)
|
||||
self.add_source_code_lines(
|
||||
self.new_table_entry("LTYPE", ltype.dxfattribs())
|
||||
)
|
||||
self.add_tags_source_code(
|
||||
ltype.pattern_tags.tags,
|
||||
prolog="tags = Tags([",
|
||||
epilog="])",
|
||||
indent=4,
|
||||
)
|
||||
self.add_source_code_line(" t.pattern_tags = LinetypePattern(tags)")
|
||||
|
||||
def _style(self, style: DXFEntity):
|
||||
self.add_source_code_lines(
|
||||
self.new_table_entry("STYLE", style.dxfattribs())
|
||||
)
|
||||
|
||||
def _dimstyle(self, dimstyle: DXFEntity):
|
||||
self.add_source_code_lines(
|
||||
self.new_table_entry("DIMSTYLE", dimstyle.dxfattribs())
|
||||
)
|
||||
|
||||
def _appid(self, appid: DXFEntity):
|
||||
self.add_source_code_lines(
|
||||
self.new_table_entry("APPID", appid.dxfattribs())
|
||||
)
|
||||
@@ -0,0 +1,721 @@
|
||||
# Copyright (c) 2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Sequence,
|
||||
Iterable,
|
||||
Optional,
|
||||
Callable,
|
||||
Union,
|
||||
)
|
||||
import abc
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
# example usage:
|
||||
# examples\addons\optimize\bin_packing_forms.py
|
||||
# examples\addons\optimize\tsp.py
|
||||
|
||||
|
||||
class DNA(abc.ABC):
|
||||
"""Abstract DNA class."""
|
||||
|
||||
fitness: Optional[float] = None
|
||||
_data: list
|
||||
|
||||
@abc.abstractmethod
|
||||
def reset(self, values: Iterable):
|
||||
...
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def is_valid(self) -> bool:
|
||||
...
|
||||
|
||||
def copy(self):
|
||||
return copy.deepcopy(self)
|
||||
|
||||
def _taint(self):
|
||||
self.fitness = None
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({str(self._data)})"
|
||||
|
||||
def __eq__(self, other):
|
||||
assert isinstance(other, self.__class__)
|
||||
return self._data == other._data
|
||||
|
||||
def __len__(self):
|
||||
return len(self._data)
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self._data.__getitem__(item)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._data.__setitem__(key, value)
|
||||
self._taint()
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._data)
|
||||
|
||||
@abc.abstractmethod
|
||||
def flip_mutate_at(self, index: int) -> None:
|
||||
...
|
||||
|
||||
|
||||
def dna_fitness(dna: DNA) -> float:
|
||||
return dna.fitness # type: ignore
|
||||
|
||||
|
||||
class Mutate(abc.ABC):
|
||||
"""Abstract mutation."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def mutate(self, dna: DNA, rate: float):
|
||||
...
|
||||
|
||||
|
||||
class FlipMutate(Mutate):
|
||||
"""Flip one bit mutation."""
|
||||
|
||||
def mutate(self, dna: DNA, rate: float):
|
||||
for index in range(len(dna)):
|
||||
if random.random() < rate:
|
||||
dna.flip_mutate_at(index)
|
||||
|
||||
|
||||
class NeighborSwapMutate(Mutate):
|
||||
"""Swap two neighbors mutation."""
|
||||
|
||||
def mutate(self, dna: DNA, rate: float):
|
||||
for index in range(len(dna)):
|
||||
if random.random() < rate:
|
||||
i2 = index - 1
|
||||
tmp = dna[i2]
|
||||
dna[i2] = dna[index]
|
||||
dna[index] = tmp
|
||||
|
||||
|
||||
class RandomSwapMutate(Mutate):
|
||||
"""Swap two random places mutation."""
|
||||
|
||||
def mutate(self, dna: DNA, rate: float):
|
||||
length = len(dna)
|
||||
for index in range(length):
|
||||
if random.random() < rate:
|
||||
i2 = random.randrange(0, length)
|
||||
if i2 == index:
|
||||
i2 -= 1
|
||||
tmp = dna[i2]
|
||||
dna[i2] = dna[index]
|
||||
dna[index] = tmp
|
||||
|
||||
|
||||
class ReverseMutate(Mutate):
|
||||
"""Reverse some consecutive bits mutation."""
|
||||
|
||||
def __init__(self, bits: int = 3):
|
||||
self._bits = int(max(bits, 1))
|
||||
|
||||
def mutate(self, dna: DNA, rate: float):
|
||||
length = len(dna)
|
||||
if random.random() < rate * (
|
||||
length / self._bits
|
||||
): # applied to all bits at ones
|
||||
i1 = random.randrange(length - self._bits)
|
||||
i2 = i1 + self._bits
|
||||
bits = dna[i1:i2]
|
||||
dna[i1:i2] = reversed(bits)
|
||||
|
||||
|
||||
class ScrambleMutate(Mutate):
|
||||
"""Scramble some consecutive bits mutation."""
|
||||
|
||||
def __init__(self, bits: int = 3):
|
||||
self._bits = int(max(bits, 1))
|
||||
|
||||
def mutate(self, dna: DNA, rate: float):
|
||||
length = len(dna)
|
||||
if random.random() < rate * (
|
||||
length / self._bits
|
||||
): # applied to all bits at ones
|
||||
i1 = random.randrange(length - self._bits)
|
||||
i2 = i1 + self._bits
|
||||
bits = dna[i1:i2]
|
||||
random.shuffle(bits)
|
||||
dna[i1:i2] = bits
|
||||
|
||||
|
||||
class Mate(abc.ABC):
|
||||
"""Abstract recombination."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def recombine(self, dna1: DNA, dna2: DNA):
|
||||
pass
|
||||
|
||||
|
||||
class Mate1pCX(Mate):
|
||||
"""One point crossover recombination."""
|
||||
|
||||
def recombine(self, dna1: DNA, dna2: DNA):
|
||||
length = len(dna1)
|
||||
index = random.randrange(0, length)
|
||||
recombine_dna_2pcx(dna1, dna2, index, length)
|
||||
|
||||
|
||||
class Mate2pCX(Mate):
|
||||
"""Two point crossover recombination."""
|
||||
|
||||
def recombine(self, dna1: DNA, dna2: DNA):
|
||||
length = len(dna1)
|
||||
i1 = random.randrange(0, length)
|
||||
i2 = random.randrange(0, length)
|
||||
if i1 > i2:
|
||||
i1, i2 = i2, i1
|
||||
recombine_dna_2pcx(dna1, dna2, i1, i2)
|
||||
|
||||
|
||||
class MateUniformCX(Mate):
|
||||
"""Uniform recombination."""
|
||||
|
||||
def recombine(self, dna1: DNA, dna2: DNA):
|
||||
for index in range(len(dna1)):
|
||||
if random.random() > 0.5:
|
||||
tmp = dna1[index]
|
||||
dna1[index] = dna2[index]
|
||||
dna2[index] = tmp
|
||||
|
||||
|
||||
class MateOrderedCX(Mate):
|
||||
"""Recombination class for ordered DNA like UniqueIntDNA()."""
|
||||
|
||||
def recombine(self, dna1: DNA, dna2: DNA):
|
||||
length = len(dna1)
|
||||
i1 = random.randrange(0, length)
|
||||
i2 = random.randrange(0, length)
|
||||
if i1 > i2:
|
||||
i1, i2 = i2, i1
|
||||
recombine_dna_ocx1(dna1, dna2, i1, i2)
|
||||
|
||||
|
||||
def recombine_dna_2pcx(dna1: DNA, dna2: DNA, i1: int, i2: int) -> None:
|
||||
"""Two point crossover."""
|
||||
part1 = dna1[i1:i2]
|
||||
part2 = dna2[i1:i2]
|
||||
dna1[i1:i2] = part2
|
||||
dna2[i1:i2] = part1
|
||||
|
||||
|
||||
def recombine_dna_ocx1(dna1: DNA, dna2: DNA, i1: int, i2: int) -> None:
|
||||
"""Ordered crossover."""
|
||||
copy1 = dna1.copy()
|
||||
replace_dna_ocx1(dna1, dna2, i1, i2)
|
||||
replace_dna_ocx1(dna2, copy1, i1, i2)
|
||||
|
||||
|
||||
def replace_dna_ocx1(dna1: DNA, dna2: DNA, i1: int, i2: int) -> None:
|
||||
"""Replace a part in dna1 by dna2 and preserve order of remaining values in
|
||||
dna1.
|
||||
"""
|
||||
old = dna1.copy()
|
||||
new = dna2[i1:i2]
|
||||
dna1[i1:i2] = new
|
||||
index = 0
|
||||
new_set = set(new)
|
||||
for value in old:
|
||||
if value in new_set:
|
||||
continue
|
||||
if index == i1:
|
||||
index = i2
|
||||
dna1[index] = value
|
||||
index += 1
|
||||
|
||||
|
||||
class FloatDNA(DNA):
|
||||
"""Arbitrary float numbers in the range [0, 1]."""
|
||||
|
||||
__slots__ = ("_data", "fitness")
|
||||
|
||||
def __init__(self, values: Iterable[float]):
|
||||
self._data: list[float] = list(values)
|
||||
self._check_valid_data()
|
||||
self.fitness: Optional[float] = None
|
||||
|
||||
@classmethod
|
||||
def random(cls, length: int) -> FloatDNA:
|
||||
return cls((random.random() for _ in range(length)))
|
||||
|
||||
@classmethod
|
||||
def n_random(cls, n: int, length: int) -> list[FloatDNA]:
|
||||
return [cls.random(length) for _ in range(n)]
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return all(0.0 <= v <= 1.0 for v in self._data)
|
||||
|
||||
def _check_valid_data(self):
|
||||
if not self.is_valid:
|
||||
raise ValueError("data value out of range")
|
||||
|
||||
def __str__(self):
|
||||
if self.fitness is None:
|
||||
fitness = ", fitness=None"
|
||||
else:
|
||||
fitness = f", fitness={self.fitness:.4f}"
|
||||
return f"{str([round(v, 4) for v in self._data])}{fitness}"
|
||||
|
||||
def reset(self, values: Iterable[float]):
|
||||
self._data = list(values)
|
||||
self._check_valid_data()
|
||||
self._taint()
|
||||
|
||||
def flip_mutate_at(self, index: int) -> None:
|
||||
self._data[index] = 1.0 - self._data[index] # flip pick location
|
||||
|
||||
|
||||
class BitDNA(DNA):
|
||||
"""One bit DNA."""
|
||||
|
||||
__slots__ = ("_data", "fitness")
|
||||
|
||||
def __init__(self, values: Iterable):
|
||||
self._data: list[bool] = list(bool(v) for v in values)
|
||||
self.fitness: Optional[float] = None
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return True # everything can be evaluated to True/False
|
||||
|
||||
@classmethod
|
||||
def random(cls, length: int) -> BitDNA:
|
||||
return cls(bool(random.randint(0, 1)) for _ in range(length))
|
||||
|
||||
@classmethod
|
||||
def n_random(cls, n: int, length: int) -> list[BitDNA]:
|
||||
return [cls.random(length) for _ in range(n)]
|
||||
|
||||
def __str__(self):
|
||||
if self.fitness is None:
|
||||
fitness = ", fitness=None"
|
||||
else:
|
||||
fitness = f", fitness={self.fitness:.4f}"
|
||||
return f"{str([int(v) for v in self._data])}{fitness}"
|
||||
|
||||
def reset(self, values: Iterable) -> None:
|
||||
self._data = list(bool(v) for v in values)
|
||||
self._taint()
|
||||
|
||||
def flip_mutate_at(self, index: int) -> None:
|
||||
self._data[index] = not self._data[index]
|
||||
|
||||
|
||||
class UniqueIntDNA(DNA):
|
||||
"""Unique integer values in the range from 0 to length-1.
|
||||
E.g. UniqueIntDNA(10) = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
|
||||
Requires MateOrderedCX() as recombination class to preserve order and
|
||||
validity after DNA recombination.
|
||||
|
||||
Requires mutation by swapping like SwapRandom(), SwapNeighbors(),
|
||||
ReversMutate() or ScrambleMutate()
|
||||
"""
|
||||
|
||||
__slots__ = ("_data", "fitness")
|
||||
|
||||
def __init__(self, values: Union[int, Iterable]):
|
||||
self._data: list[int]
|
||||
if isinstance(values, int):
|
||||
self._data = list(range(values))
|
||||
else:
|
||||
self._data = [int(v) for v in values]
|
||||
if not self.is_valid:
|
||||
raise TypeError(self._data)
|
||||
self.fitness: Optional[float] = None
|
||||
|
||||
@classmethod
|
||||
def random(cls, length: int) -> UniqueIntDNA:
|
||||
dna = cls(length)
|
||||
random.shuffle(dna._data)
|
||||
return dna
|
||||
|
||||
@classmethod
|
||||
def n_random(cls, n: int, length: int) -> list[UniqueIntDNA]:
|
||||
return [cls.random(length) for _ in range(n)]
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return len(set(self._data)) == len(self._data)
|
||||
|
||||
def __str__(self):
|
||||
if self.fitness is None:
|
||||
fitness = ", fitness=None"
|
||||
else:
|
||||
fitness = f", fitness={self.fitness:.4f}"
|
||||
return f"{str([int(v) for v in self._data])}{fitness}"
|
||||
|
||||
def reset(self, values: Iterable) -> None:
|
||||
self._data = list(int(v) for v in values)
|
||||
self._taint()
|
||||
|
||||
def flip_mutate_at(self, index: int) -> None:
|
||||
raise TypeError("flip mutation not supported")
|
||||
|
||||
|
||||
class IntegerDNA(DNA):
|
||||
"""Integer values in the range from 0 to max_ - 1.
|
||||
E.g. IntegerDNA([0, 1, 2, 3, 4, 0, 1, 2, 3, 4], 5)
|
||||
"""
|
||||
|
||||
__slots__ = ("_data", "fitness")
|
||||
|
||||
def __init__(self, values: Iterable[int], max_: int):
|
||||
self._max = int(max_)
|
||||
self._data: list[int] = list(values)
|
||||
if not self.is_valid:
|
||||
raise TypeError(self._data)
|
||||
self.fitness: Optional[float] = None
|
||||
|
||||
@classmethod
|
||||
def random(cls, length: int, max_: int) -> IntegerDNA:
|
||||
imax = int(max_)
|
||||
return cls((random.randrange(0, imax) for _ in range(length)), imax)
|
||||
|
||||
@classmethod
|
||||
def n_random(cls, n: int, length: int, max_: int) -> list[IntegerDNA]:
|
||||
return [cls.random(length, max_) for _ in range(n)]
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return all(0 <= v < self._max for v in self._data)
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({str(self._data)}, {self._max})"
|
||||
|
||||
def __str__(self):
|
||||
if self.fitness is None:
|
||||
fitness = ", fitness=None"
|
||||
else:
|
||||
fitness = f", fitness={self.fitness:.4f}"
|
||||
return f"{str([int(v) for v in self._data])}{fitness}"
|
||||
|
||||
def reset(self, values: Iterable) -> None:
|
||||
self._data = list(int(v) for v in values)
|
||||
self._taint()
|
||||
|
||||
def flip_mutate_at(self, index: int) -> None:
|
||||
self._data[index] = self._max - self._data[index] - 1
|
||||
|
||||
|
||||
class Selection(abc.ABC):
|
||||
"""Abstract selection class."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def pick(self, count: int) -> Iterable[DNA]:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def reset(self, candidates: Iterable[DNA]):
|
||||
...
|
||||
|
||||
|
||||
class Evaluator(abc.ABC):
|
||||
"""Abstract evaluation class."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def evaluate(self, dna: DNA) -> float:
|
||||
...
|
||||
|
||||
|
||||
class Log:
|
||||
@dataclass
|
||||
class Entry:
|
||||
runtime: float
|
||||
fitness: float
|
||||
avg_fitness: float
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.entries: list[Log.Entry] = []
|
||||
|
||||
def add(self, runtime: float, fitness: float, avg_fitness: float) -> None:
|
||||
self.entries.append(Log.Entry(runtime, fitness, avg_fitness))
|
||||
|
||||
def dump(self, filename: str) -> None:
|
||||
data = [(e.runtime, e.fitness, e.avg_fitness) for e in self.entries]
|
||||
with open(filename, "wt") as fp:
|
||||
json.dump(data, fp, indent=4)
|
||||
|
||||
@classmethod
|
||||
def load(cls, filename: str) -> Log:
|
||||
with open(filename, "rt") as fp:
|
||||
data = json.load(fp)
|
||||
log = Log()
|
||||
for runtime, fitness, avg_fitness in data:
|
||||
log.add(runtime, fitness, avg_fitness)
|
||||
return log
|
||||
|
||||
|
||||
class HallOfFame:
|
||||
def __init__(self, count):
|
||||
self.count = count
|
||||
self._unique_entries = dict()
|
||||
|
||||
def __iter__(self):
|
||||
return (
|
||||
self._unique_entries[k] for k in self._sorted_keys()[: self.count]
|
||||
)
|
||||
|
||||
def _sorted_keys(self):
|
||||
return sorted(self._unique_entries.keys(), reverse=True)
|
||||
|
||||
def add(self, dna: DNA):
|
||||
assert dna.fitness is not None
|
||||
self._unique_entries[dna.fitness] = dna
|
||||
|
||||
def get(self, count: int) -> list[DNA]:
|
||||
entries = self._unique_entries
|
||||
keys = self._sorted_keys()
|
||||
return [entries[k] for k in keys[: min(count, self.count)]]
|
||||
|
||||
def purge(self):
|
||||
if len(self._unique_entries) <= self.count:
|
||||
return
|
||||
entries = self._unique_entries
|
||||
keys = self._sorted_keys()
|
||||
self._unique_entries = {k: entries[k] for k in keys[: self.count]}
|
||||
|
||||
|
||||
def threshold_filter(
|
||||
candidates: Sequence[DNA], best_fitness: float, threshold: float
|
||||
) -> Iterable[DNA]:
|
||||
if best_fitness <= 0.0:
|
||||
minimum = min(candidates, key=dna_fitness)
|
||||
min_value = (1.0 - threshold) * minimum.fitness # type: ignore
|
||||
else:
|
||||
min_value = best_fitness * threshold
|
||||
return (c for c in candidates if c.fitness > min_value) # type: ignore
|
||||
|
||||
|
||||
class GeneticOptimizer:
|
||||
"""A genetic algorithm (GA) is a meta-heuristic inspired by the process of
|
||||
natural selection. Genetic algorithms are commonly used to generate
|
||||
high-quality solutions to optimization and search problems by relying on
|
||||
biologically inspired operators such as mutation, crossover and selection.
|
||||
|
||||
Source: https://en.wikipedia.org/wiki/Genetic_algorithm
|
||||
|
||||
This implementation searches always for the maximum fitness, fitness
|
||||
comparisons are always done by the "greater than" operator (">").
|
||||
The algorithm supports negative values to search for the minimum fitness
|
||||
(e.g. Travelling Salesmen Problem: -900 > -1000). Reset the start fitness
|
||||
by the method :meth:`reset_fitness` accordingly::
|
||||
|
||||
optimizer.reset_fitness(-1e99)
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
evaluator: Evaluator,
|
||||
max_generations: int,
|
||||
max_fitness: float = 1.0,
|
||||
):
|
||||
if max_generations < 1:
|
||||
raise ValueError("requires max_generations > 0")
|
||||
# data:
|
||||
self.name = "GeneticOptimizer"
|
||||
self.log = Log()
|
||||
self.candidates: list[DNA] = []
|
||||
|
||||
# core components:
|
||||
self.evaluator: Evaluator = evaluator
|
||||
self.selection: Selection = RouletteSelection()
|
||||
self.mate: Mate = Mate2pCX()
|
||||
self.mutation = FlipMutate()
|
||||
|
||||
# options:
|
||||
self.max_generations = int(max_generations)
|
||||
self.max_fitness: float = float(max_fitness)
|
||||
self.max_runtime: float = 1e99
|
||||
self.max_stagnation = 100
|
||||
self.crossover_rate = 0.70
|
||||
self.mutation_rate = 0.01
|
||||
self.elitism: int = 2
|
||||
# percentage (0.1 = 10%) of candidates with least fitness to ignore in
|
||||
# next generation
|
||||
self.threshold: float = 0.0
|
||||
|
||||
# state of last (current) generation:
|
||||
self.generation: int = 0
|
||||
self.start_time = 0.0
|
||||
self.runtime: float = 0.0
|
||||
self.best_dna: DNA = BitDNA([])
|
||||
self.best_fitness: float = 0.0
|
||||
self.stagnation: int = 0 # generations without improvement
|
||||
self.hall_of_fame = HallOfFame(10)
|
||||
|
||||
def reset_fitness(self, value: float) -> None:
|
||||
self.best_fitness = float(value)
|
||||
|
||||
@property
|
||||
def is_executed(self) -> bool:
|
||||
return bool(self.generation)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self.candidates)
|
||||
|
||||
def add_candidates(self, dna: Iterable[DNA]):
|
||||
if not self.is_executed:
|
||||
self.candidates.extend(dna)
|
||||
else:
|
||||
raise TypeError("already executed")
|
||||
|
||||
def execute(
|
||||
self,
|
||||
feedback: Optional[Callable[[GeneticOptimizer], bool]] = None,
|
||||
interval: float = 1.0,
|
||||
) -> None:
|
||||
if self.is_executed:
|
||||
raise TypeError("can only run once")
|
||||
if not self.candidates:
|
||||
print("no DNA defined!")
|
||||
t0 = time.perf_counter()
|
||||
self.start_time = t0
|
||||
for self.generation in range(1, self.max_generations + 1):
|
||||
self.measure_fitness()
|
||||
t1 = time.perf_counter()
|
||||
self.runtime = t1 - self.start_time
|
||||
if (
|
||||
self.best_fitness >= self.max_fitness
|
||||
or self.runtime >= self.max_runtime
|
||||
or self.stagnation >= self.max_stagnation
|
||||
):
|
||||
break
|
||||
if feedback and t1 - t0 > interval:
|
||||
if feedback(self): # stop if feedback() returns True
|
||||
break
|
||||
t0 = t1
|
||||
self.next_generation()
|
||||
|
||||
def measure_fitness(self) -> None:
|
||||
self.stagnation += 1
|
||||
fitness_sum: float = 0.0
|
||||
for dna in self.candidates:
|
||||
if dna.fitness is not None:
|
||||
fitness_sum += dna.fitness
|
||||
continue
|
||||
fitness = self.evaluator.evaluate(dna)
|
||||
dna.fitness = fitness
|
||||
fitness_sum += fitness
|
||||
self.hall_of_fame.add(dna)
|
||||
if fitness > self.best_fitness:
|
||||
self.best_fitness = fitness
|
||||
self.best_dna = dna
|
||||
self.stagnation = 0
|
||||
|
||||
self.hall_of_fame.purge()
|
||||
try:
|
||||
avg_fitness = fitness_sum / len(self.candidates)
|
||||
except ZeroDivisionError:
|
||||
avg_fitness = 0.0
|
||||
self.log.add(
|
||||
time.perf_counter() - self.start_time,
|
||||
self.best_fitness,
|
||||
avg_fitness,
|
||||
)
|
||||
|
||||
def next_generation(self) -> None:
|
||||
count = len(self.candidates)
|
||||
candidates: list[DNA] = []
|
||||
selector = self.selection
|
||||
selector.reset(self.filter_threshold(self.candidates))
|
||||
|
||||
if self.elitism > 0:
|
||||
candidates.extend(self.hall_of_fame.get(self.elitism))
|
||||
|
||||
while len(candidates) < count:
|
||||
dna1, dna2 = selector.pick(2)
|
||||
dna1 = dna1.copy()
|
||||
dna2 = dna2.copy()
|
||||
self.recombine(dna1, dna2)
|
||||
self.mutate(dna1, dna2)
|
||||
candidates.append(dna1)
|
||||
candidates.append(dna2)
|
||||
self.candidates = candidates
|
||||
|
||||
def filter_threshold(self, candidates: Sequence[DNA]) -> Iterable[DNA]:
|
||||
if self.threshold > 0.0:
|
||||
return threshold_filter(
|
||||
candidates, self.best_fitness, self.threshold
|
||||
)
|
||||
else:
|
||||
return candidates
|
||||
|
||||
def recombine(self, dna1: DNA, dna2: DNA):
|
||||
if random.random() < self.crossover_rate:
|
||||
self.mate.recombine(dna1, dna2)
|
||||
|
||||
def mutate(self, dna1: DNA, dna2: DNA):
|
||||
self.mutation.mutate(dna1, self.mutation_rate)
|
||||
self.mutation.mutate(dna2, self.mutation_rate)
|
||||
|
||||
|
||||
def conv_negative_weights(weights: Iterable[float]) -> Iterable[float]:
|
||||
# random.choices does not accept negative values: -100 -> 1/100, -10 -> 1/10
|
||||
return (0.0 if w == 0.0 else 1.0 / abs(w) for w in weights)
|
||||
|
||||
|
||||
class RouletteSelection(Selection):
|
||||
"""Selection by fitness values."""
|
||||
|
||||
def __init__(self, negative_values: bool = False) -> None:
|
||||
self._candidates: list[DNA] = []
|
||||
self._weights: list[float] = []
|
||||
self._negative_values = bool(negative_values)
|
||||
|
||||
def reset(self, candidates: Iterable[DNA]):
|
||||
# dna.fitness is not None here!
|
||||
self._candidates = list(candidates)
|
||||
if self._negative_values:
|
||||
self._weights = list(
|
||||
conv_negative_weights(dna.fitness for dna in self._candidates) # type: ignore
|
||||
)
|
||||
else:
|
||||
self._weights = [dna.fitness for dna in self._candidates] # type: ignore
|
||||
|
||||
def pick(self, count: int) -> Iterable[DNA]:
|
||||
return random.choices(self._candidates, self._weights, k=count)
|
||||
|
||||
|
||||
class RankBasedSelection(RouletteSelection):
|
||||
"""Selection by rank of fitness."""
|
||||
|
||||
def reset(self, candidates: Iterable[DNA]):
|
||||
# dna.fitness is not None here!
|
||||
self._candidates = list(candidates)
|
||||
self._candidates.sort(key=dna_fitness)
|
||||
# weight of best_fitness == len(strands)
|
||||
# and decreases until 1 for the least fitness
|
||||
self._weights = list(range(1, len(self._candidates) + 1))
|
||||
|
||||
|
||||
class TournamentSelection(Selection):
|
||||
"""Selection by choosing the best of a certain count of candidates."""
|
||||
|
||||
def __init__(self, candidates: int):
|
||||
self._candidates: list[DNA] = []
|
||||
self.candidates = candidates
|
||||
|
||||
def reset(self, candidates: Iterable[DNA]):
|
||||
self._candidates = list(candidates)
|
||||
|
||||
def pick(self, count: int) -> Iterable[DNA]:
|
||||
for _ in range(count):
|
||||
values = [
|
||||
random.choice(self._candidates) for _ in range(self.candidates)
|
||||
]
|
||||
values.sort(key=dna_fitness)
|
||||
yield values[-1]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) 2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# This add-on was created to solve this problem: https://github.com/mozman/ezdxf/discussions/789
|
||||
from __future__ import annotations
|
||||
from typing import TextIO
|
||||
import os
|
||||
import io
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.layouts import Modelspace
|
||||
from ezdxf.lldxf.tagwriter import TagWriter, AbstractTagWriter
|
||||
|
||||
__all__ = ["export_file", "export_stream"]
|
||||
|
||||
|
||||
def export_file(doc: Drawing, filename: str | os.PathLike) -> None:
|
||||
"""Exports the specified DXF R12 document, which should contain content conforming
|
||||
to the ASTM-D6673-10 standard, in a special way so that Gerber Technology applications
|
||||
can parse it by their low-quality DXF parser.
|
||||
"""
|
||||
fp = io.open(filename, mode="wt", encoding="ascii", errors="dxfreplace")
|
||||
export_stream(doc, fp)
|
||||
|
||||
|
||||
def export_stream(doc: Drawing, stream: TextIO) -> None:
|
||||
"""Exports the specified DXF R12 document into a `stream` object."""
|
||||
|
||||
if doc.dxfversion != ezdxf.const.DXF12:
|
||||
raise ezdxf.DXFVersionError("only DXF R12 format is supported")
|
||||
tagwriter = TagWriter(stream, write_handles=False, dxfversion=ezdxf.const.DXF12)
|
||||
_export_sections(doc, tagwriter)
|
||||
|
||||
|
||||
def _export_sections(doc: Drawing, tagwriter: AbstractTagWriter) -> None:
|
||||
_export_header(tagwriter)
|
||||
_export_blocks(doc, tagwriter)
|
||||
_export_entities(doc.modelspace(), tagwriter)
|
||||
|
||||
|
||||
def _export_header(tagwriter: AbstractTagWriter) -> None:
|
||||
# export empty header section
|
||||
tagwriter.write_str(" 0\nSECTION\n 2\nHEADER\n")
|
||||
tagwriter.write_tag2(0, "ENDSEC")
|
||||
|
||||
|
||||
def _export_blocks(doc: Drawing, tagwriter: AbstractTagWriter) -> None:
|
||||
# This is the important part:
|
||||
#
|
||||
# Gerber Technology applications have a bad DXF parser which do not accept DXF
|
||||
# files that contain blocks without ASTM-D6673-10 content, such as the standard
|
||||
# $MODEL_SPACE and $PAPER_SPACE block definitions.
|
||||
#
|
||||
# This is annoying but the presence of these blocks is NOT mandatory for
|
||||
# the DXF R12 standard.
|
||||
#
|
||||
tagwriter.write_str(" 0\nSECTION\n 2\nBLOCKS\n")
|
||||
for block_record in doc.block_records:
|
||||
# export only BLOCK definitions, ignore LAYOUT definition blocks
|
||||
if block_record.is_block_layout:
|
||||
block_record.export_block_definition(tagwriter)
|
||||
tagwriter.write_tag2(0, "ENDSEC")
|
||||
|
||||
|
||||
def _export_entities(msp: Modelspace, tagwriter: AbstractTagWriter) -> None:
|
||||
tagwriter.write_str(" 0\nSECTION\n 2\nENTITIES\n")
|
||||
msp.entity_space.export_dxf(tagwriter)
|
||||
tagwriter.write_tag2(0, "ENDSEC")
|
||||
tagwriter.write_tag2(0, "EOF")
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
#
|
||||
# 1 plot unit (plu) = 0.025mm
|
||||
# 40 plu = 1mm
|
||||
# 1016 plu = 1 inch
|
||||
# 3.39 plu = 1 dot @300 dpi
|
||||
@@ -0,0 +1,398 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf import zoom, colors
|
||||
from ezdxf.addons import xplayer
|
||||
from ezdxf.addons.drawing import svg, layout, pymupdf, dxf
|
||||
from ezdxf.addons.drawing.dxf import ColorMode
|
||||
|
||||
from .tokenizer import hpgl2_commands
|
||||
from .plotter import Plotter
|
||||
from .interpreter import Interpreter
|
||||
from .backend import Recorder, placement_matrix, Player
|
||||
|
||||
|
||||
DEBUG = False
|
||||
ENTER_HPGL2_MODE = b"%1B"
|
||||
|
||||
|
||||
class Hpgl2Error(Exception):
|
||||
"""Base exception for the :mod:`hpgl2` add-on."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Hpgl2DataNotFound(Hpgl2Error):
|
||||
"""No HPGL/2 data was found, maybe the "Enter HPGL/2 mode" escape sequence is missing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class EmptyDrawing(Hpgl2Error):
|
||||
"""The HPGL/2 commands do not produce any content."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MergeControl(enum.IntEnum):
|
||||
NONE = 0 # print order
|
||||
LUMINANCE = 1 # sort filled polygons by luminance
|
||||
AUTO = 2 # guess best method
|
||||
|
||||
|
||||
def to_dxf(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
color_mode=ColorMode.RGB,
|
||||
merge_control: MergeControl = MergeControl.AUTO,
|
||||
) -> Drawing:
|
||||
"""
|
||||
Exports the HPGL/2 commands of the byte stream `b` as a DXF document.
|
||||
|
||||
The page content is created at the origin of the modelspace and 1 drawing unit is 1
|
||||
plot unit (1 plu = 0.025mm) unless scaling values are provided.
|
||||
|
||||
The content of HPGL files is intended to be plotted on white paper, therefore a white
|
||||
filling will be added as background in color mode :attr:`RGB`.
|
||||
|
||||
All entities are assigned to a layer according to the pen number with the name scheme
|
||||
``PEN_<###>``. In order to be able to process the file better, it is also possible to
|
||||
assign the :term:`ACI` color by layer by setting the argument `color_mode` to
|
||||
:attr:`ColorMode.ACI`, but then the RGB color is lost because the RGB color has
|
||||
always the higher priority over the :term:`ACI`.
|
||||
|
||||
The first paperspace layout "Layout1" of the DXF document is set up to print the entire
|
||||
modelspace on one sheet, the size of the page is the size of the original plot file in
|
||||
millimeters.
|
||||
|
||||
HPGL/2's merge control works at the pixel level and cannot be replicated by DXF,
|
||||
but to prevent fillings from obscuring text, the filled polygons are
|
||||
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
|
||||
see also :class:`MergeControl` enum.
|
||||
|
||||
Args:
|
||||
b: plot file content as bytes
|
||||
rotation: rotation angle of 0, 90, 180 or 270 degrees
|
||||
mirror_x: mirror in x-axis direction
|
||||
mirror_y: mirror in y-axis direction
|
||||
color_mode: the color mode controls how color values are assigned to DXF entities,
|
||||
see :class:`ColorMode`
|
||||
merge_control: how to order filled polygons, see :class:`MergeControl`
|
||||
|
||||
Returns: DXF document as instance of class :class:`~ezdxf.document.Drawing`
|
||||
|
||||
"""
|
||||
if rotation not in (0, 90, 180, 270):
|
||||
raise ValueError("rotation angle must be 0, 90, 180, or 270")
|
||||
|
||||
# 1st pass records output of the plotting commands and detects the bounding box
|
||||
doc = ezdxf.new()
|
||||
try:
|
||||
player = record_plotter_output(b, merge_control)
|
||||
except Hpgl2Error:
|
||||
return doc
|
||||
|
||||
bbox = player.bbox()
|
||||
m = placement_matrix(bbox, -1 if mirror_x else 1, -1 if mirror_y else 1, rotation)
|
||||
player.transform(m)
|
||||
bbox = player.bbox()
|
||||
msp = doc.modelspace()
|
||||
dxf_backend = dxf.DXFBackend(msp, color_mode=color_mode)
|
||||
bg_color = colors.RGB(255, 255, 255)
|
||||
if color_mode == ColorMode.RGB:
|
||||
doc.layers.add("BACKGROUND")
|
||||
bg = dxf.add_background(msp, bbox, color=bg_color)
|
||||
bg.dxf.layer = "BACKGROUND"
|
||||
|
||||
# 2nd pass replays the plotting commands to plot the DXF
|
||||
# exports the HPGL/2 content in plot units (plu) as modelspace:
|
||||
# 1 plu = 0.025mm or 40 plu == 1mm
|
||||
xplayer.hpgl2_to_drawing(player, dxf_backend, bg_color=bg_color.to_hex())
|
||||
del player
|
||||
|
||||
if bbox.has_data: # non-empty page
|
||||
zoom.window(msp, bbox.extmin, bbox.extmax)
|
||||
dxf.update_extents(doc, bbox)
|
||||
# paperspace is set up in mm:
|
||||
dxf.setup_paperspace(doc, bbox)
|
||||
return doc
|
||||
|
||||
|
||||
def to_svg(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
merge_control=MergeControl.AUTO,
|
||||
) -> str:
|
||||
"""
|
||||
Exports the HPGL/2 commands of the byte stream `b` as SVG string.
|
||||
|
||||
The plot units are mapped 1:1 to ``viewBox`` units and the size of image is the size
|
||||
of the original plot file in millimeters.
|
||||
|
||||
HPGL/2's merge control works at the pixel level and cannot be replicated by the
|
||||
backend, but to prevent fillings from obscuring text, the filled polygons are
|
||||
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
|
||||
see also :class:`MergeControl` enum.
|
||||
|
||||
Args:
|
||||
b: plot file content as bytes
|
||||
rotation: rotation angle of 0, 90, 180 or 270 degrees
|
||||
mirror_x: mirror in x-axis direction
|
||||
mirror_y: mirror in y-axis direction
|
||||
merge_control: how to order filled polygons, see :class:`MergeControl`
|
||||
|
||||
Returns: SVG content as ``str``
|
||||
|
||||
"""
|
||||
if rotation not in (0, 90, 180, 270):
|
||||
raise ValueError("rotation angle must be 0, 90, 180, or 270")
|
||||
# 1st pass records output of the plotting commands and detects the bounding box
|
||||
try:
|
||||
player = record_plotter_output(b, merge_control)
|
||||
except Hpgl2Error:
|
||||
return ""
|
||||
|
||||
# transform content for the SVGBackend of the drawing add-on
|
||||
bbox = player.bbox()
|
||||
size = bbox.size
|
||||
|
||||
# HPGL/2 uses (integer) plot units, 1 plu = 0.025mm or 1mm = 40 plu
|
||||
width_in_mm = size.x / 40
|
||||
height_in_mm = size.y / 40
|
||||
|
||||
if rotation in (0, 180):
|
||||
page = layout.Page(width_in_mm, height_in_mm)
|
||||
else:
|
||||
page = layout.Page(height_in_mm, width_in_mm)
|
||||
# adjust rotation for y-axis mirroring
|
||||
rotation += 180
|
||||
|
||||
# The SVGBackend expects coordinates in the range [0, output_coordinate_space]
|
||||
max_plot_units = round(max(size.x, size.y))
|
||||
settings = layout.Settings(output_coordinate_space=max_plot_units)
|
||||
m = layout.placement_matrix(
|
||||
bbox,
|
||||
sx=-1 if mirror_x else 1,
|
||||
sy=1 if mirror_y else -1, # inverted y-axis
|
||||
rotation=rotation,
|
||||
page=page,
|
||||
output_coordinate_space=settings.output_coordinate_space,
|
||||
)
|
||||
player.transform(m)
|
||||
|
||||
# 2nd pass replays the plotting commands on the SVGBackend of the drawing add-on
|
||||
svg_backend = svg.SVGRenderBackend(page, settings)
|
||||
xplayer.hpgl2_to_drawing(player, svg_backend, bg_color="#ffffff")
|
||||
del player
|
||||
xml = svg_backend.get_xml_root_element()
|
||||
return ET.tostring(xml, encoding="unicode", xml_declaration=True)
|
||||
|
||||
|
||||
def to_pdf(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
merge_control=MergeControl.AUTO,
|
||||
) -> bytes:
|
||||
"""
|
||||
Exports the HPGL/2 commands of the byte stream `b` as PDF data.
|
||||
|
||||
The plot units (1 plu = 0.025mm) are converted to PDF units (1/72 inch) so the image
|
||||
has the size of the original plot file.
|
||||
|
||||
HPGL/2's merge control works at the pixel level and cannot be replicated by the
|
||||
backend, but to prevent fillings from obscuring text, the filled polygons are
|
||||
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
|
||||
see also :class:`MergeControl` enum.
|
||||
|
||||
Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/
|
||||
|
||||
Args:
|
||||
b: plot file content as bytes
|
||||
rotation: rotation angle of 0, 90, 180 or 270 degrees
|
||||
mirror_x: mirror in x-axis direction
|
||||
mirror_y: mirror in y-axis direction
|
||||
merge_control: how to order filled polygons, see :class:`MergeControl`
|
||||
|
||||
Returns: PDF content as ``bytes``
|
||||
|
||||
"""
|
||||
return _pymupdf(
|
||||
b,
|
||||
rotation=rotation,
|
||||
mirror_x=mirror_x,
|
||||
mirror_y=mirror_y,
|
||||
merge_control=merge_control,
|
||||
fmt="pdf",
|
||||
)
|
||||
|
||||
|
||||
def to_pixmap(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
merge_control=MergeControl.AUTO,
|
||||
fmt: str = "png",
|
||||
dpi: int = 96,
|
||||
) -> bytes:
|
||||
"""
|
||||
Exports the HPGL/2 commands of the byte stream `b` as pixel image.
|
||||
|
||||
Supported image formats:
|
||||
|
||||
=== =========================
|
||||
png Portable Network Graphics
|
||||
ppm Portable Pixmap
|
||||
pbm Portable Bitmap
|
||||
=== =========================
|
||||
|
||||
The plot units (1 plu = 0.025mm) are converted to dot per inch (dpi) so the image
|
||||
has the size of the original plot file.
|
||||
|
||||
HPGL/2's merge control works at the pixel level and cannot be replicated by the
|
||||
backend, but to prevent fillings from obscuring text, the filled polygons are
|
||||
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
|
||||
see also :class:`MergeControl` enum.
|
||||
|
||||
Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/
|
||||
|
||||
Args:
|
||||
b: plot file content as bytes
|
||||
rotation: rotation angle of 0, 90, 180 or 270 degrees
|
||||
mirror_x: mirror in x-axis direction
|
||||
mirror_y: mirror in y-axis direction
|
||||
merge_control: how to order filled polygons, see :class:`MergeControl`
|
||||
fmt: image format
|
||||
dpi: output resolution in dots per inch
|
||||
|
||||
Returns: image content as ``bytes``
|
||||
|
||||
"""
|
||||
fmt = fmt.lower()
|
||||
if fmt not in pymupdf.SUPPORTED_IMAGE_FORMATS:
|
||||
raise ValueError(f"image format '{fmt}' not supported")
|
||||
return _pymupdf(
|
||||
b,
|
||||
rotation=rotation,
|
||||
mirror_x=mirror_x,
|
||||
mirror_y=mirror_y,
|
||||
merge_control=merge_control,
|
||||
fmt=fmt,
|
||||
dpi=dpi,
|
||||
)
|
||||
|
||||
|
||||
def _pymupdf(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
merge_control=MergeControl.AUTO,
|
||||
fmt: str = "pdf",
|
||||
dpi=96,
|
||||
) -> bytes:
|
||||
if not pymupdf.is_pymupdf_installed:
|
||||
print("Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/")
|
||||
return b""
|
||||
|
||||
if rotation not in (0, 90, 180, 270):
|
||||
raise ValueError("rotation angle must be 0, 90, 180, or 270")
|
||||
# 1st pass records output of the plotting commands and detects the bounding box
|
||||
try:
|
||||
player = record_plotter_output(b, merge_control)
|
||||
except Hpgl2Error:
|
||||
return b""
|
||||
|
||||
# transform content for the SVGBackend of the drawing add-on
|
||||
bbox = player.bbox()
|
||||
size = bbox.size
|
||||
|
||||
# HPGL/2 uses (integer) plot units, 1 plu = 0.025mm or 1mm = 40 plu
|
||||
width_in_mm = size.x / 40
|
||||
height_in_mm = size.y / 40
|
||||
|
||||
if rotation in (0, 180):
|
||||
page = layout.Page(width_in_mm, height_in_mm)
|
||||
else:
|
||||
page = layout.Page(height_in_mm, width_in_mm)
|
||||
# adjust rotation for y-axis mirroring
|
||||
rotation += 180
|
||||
|
||||
# The PDFBackend expects coordinates as pt = 1/72 inch; 1016 plu = 1 inch
|
||||
max_plot_units = max(size.x, size.y) / 1016 * 72
|
||||
settings = layout.Settings(output_coordinate_space=max_plot_units)
|
||||
m = layout.placement_matrix(
|
||||
bbox,
|
||||
sx=-1 if mirror_x else 1,
|
||||
sy=-1 if mirror_y else 1,
|
||||
rotation=rotation,
|
||||
page=page,
|
||||
output_coordinate_space=settings.output_coordinate_space,
|
||||
)
|
||||
player.transform(m)
|
||||
|
||||
# 2nd pass replays the plotting commands on the PyMuPdfBackend of the drawing add-on
|
||||
pymupdf_backend = pymupdf.PyMuPdfBackend()
|
||||
xplayer.hpgl2_to_drawing(player, pymupdf_backend, bg_color="#ffffff")
|
||||
del player
|
||||
if fmt == "pdf":
|
||||
return pymupdf_backend.get_pdf_bytes(page, settings=settings)
|
||||
else:
|
||||
return pymupdf_backend.get_pixmap_bytes(
|
||||
page, fmt=fmt, settings=settings, dpi=dpi
|
||||
)
|
||||
|
||||
|
||||
def print_interpreter_log(interpreter: Interpreter) -> None:
|
||||
print("HPGL/2 interpreter log:")
|
||||
print(f"unsupported commands: {interpreter.not_implemented_commands}")
|
||||
if interpreter.errors:
|
||||
print("parsing errors:")
|
||||
for err in interpreter.errors:
|
||||
print(err)
|
||||
|
||||
|
||||
def record_plotter_output(
|
||||
b: bytes,
|
||||
merge_control: MergeControl,
|
||||
) -> Player:
|
||||
commands = hpgl2_commands(b)
|
||||
if len(commands) == 0:
|
||||
print("HPGL2 data not found.")
|
||||
raise Hpgl2DataNotFound
|
||||
|
||||
recorder = Recorder()
|
||||
plotter = Plotter(recorder)
|
||||
interpreter = Interpreter(plotter)
|
||||
interpreter.run(commands)
|
||||
if DEBUG:
|
||||
print_interpreter_log(interpreter)
|
||||
player = recorder.player()
|
||||
bbox = player.bbox()
|
||||
if not bbox.has_data:
|
||||
raise EmptyDrawing
|
||||
|
||||
if merge_control == MergeControl.AUTO:
|
||||
if plotter.has_merge_control:
|
||||
merge_control = MergeControl.LUMINANCE
|
||||
if merge_control == MergeControl.LUMINANCE:
|
||||
if DEBUG:
|
||||
print("merge control on: sorting filled polygons by luminance")
|
||||
player.sort_filled_paths()
|
||||
return player
|
||||
@@ -0,0 +1,237 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, NamedTuple, Any, Iterator
|
||||
from typing_extensions import Self
|
||||
import abc
|
||||
import copy
|
||||
import enum
|
||||
import math
|
||||
from .deps import (
|
||||
Vec2,
|
||||
Path,
|
||||
colors,
|
||||
Matrix44,
|
||||
BoundingBox2d,
|
||||
)
|
||||
from .properties import Properties, Pen
|
||||
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d
|
||||
|
||||
# Page coordinates are always plot units:
|
||||
# 1 plot unit (plu) = 0.025mm
|
||||
# 40 plu = 1mm
|
||||
# 1016 plu = 1 inch
|
||||
# 3.39 plu = 1 dot @300 dpi
|
||||
# positive x-axis is horizontal from left to right
|
||||
# positive y-axis is vertical from bottom to top
|
||||
|
||||
|
||||
class Backend(abc.ABC):
|
||||
"""Abstract base class for implementing a low level output backends."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None:
|
||||
"""Draws a polyline from a sequence `points`. The input coordinates are page
|
||||
coordinates in plot units. The `points` sequence can contain 0 or more
|
||||
points!
|
||||
|
||||
Args:
|
||||
properties: display :class:`Properties` for the polyline
|
||||
points: sequence of :class:`ezdxf.math.Vec2` instances
|
||||
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_paths(
|
||||
self, properties: Properties, paths: Sequence[Path], filled: bool
|
||||
) -> None:
|
||||
"""Draws filled or outline paths from the sequence of `paths`. The input coordinates
|
||||
are page coordinates in plot units. The `paths` sequence can contain 0 or more
|
||||
single :class:`~ezdxf.path.Path` instances. Draws outline paths if
|
||||
Properties.FillType is NONE and filled paths otherwise.
|
||||
|
||||
Args:
|
||||
properties: display :class:`Properties` for the filled polygon
|
||||
paths: sequence of single :class:`ezdxf.path.Path` instances
|
||||
filled: draw filled paths if ``True`` otherwise outline paths
|
||||
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class RecordType(enum.Enum):
|
||||
POLYLINE = enum.auto()
|
||||
FILLED_PATHS = enum.auto()
|
||||
OUTLINE_PATHS = enum.auto()
|
||||
|
||||
|
||||
class DataRecord(NamedTuple):
|
||||
type: RecordType
|
||||
property_hash: int
|
||||
data: Any
|
||||
|
||||
|
||||
class Recorder(Backend):
|
||||
"""The :class:`Recorder` class records the output of the :class:`Plotter` class.
|
||||
|
||||
All input coordinates are page coordinates:
|
||||
|
||||
- 1 plot unit (plu) = 0.025mm
|
||||
- 40 plu = 1 mm
|
||||
- 1016 plu = 1 inch
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._records: list[DataRecord] = []
|
||||
self._properties: dict[int, Properties] = {}
|
||||
self._pens: Sequence[Pen] = []
|
||||
|
||||
def player(self) -> Player:
|
||||
"""Returns a :class:`Player` instance with the original recordings. Make a copy
|
||||
of this player to protect the original recordings from being modified::
|
||||
|
||||
safe_player = recorder.player().copy()
|
||||
|
||||
"""
|
||||
return Player(self._records, self._properties)
|
||||
|
||||
def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None:
|
||||
self.store(RecordType.POLYLINE, properties, NumpyPoints2d(points))
|
||||
|
||||
def draw_paths(
|
||||
self, properties: Properties, paths: Sequence[Path], filled: bool
|
||||
) -> None:
|
||||
data = tuple(map(NumpyPath2d, paths))
|
||||
record_type = RecordType.FILLED_PATHS if filled else RecordType.OUTLINE_PATHS
|
||||
self.store(record_type, properties, data)
|
||||
|
||||
def store(self, record_type: RecordType, properties: Properties, args) -> None:
|
||||
prop_hash = properties.hash()
|
||||
if prop_hash not in self._properties:
|
||||
self._properties[prop_hash] = properties.copy()
|
||||
self._records.append(DataRecord(record_type, prop_hash, args))
|
||||
if len(self._pens) != len(properties.pen_table):
|
||||
self._pens = list(properties.pen_table.values())
|
||||
|
||||
|
||||
class Player:
|
||||
"""This class replays the recordings of the :class:`Recorder` class on another
|
||||
backend. The class can modify the recorded output.
|
||||
"""
|
||||
|
||||
def __init__(self, records: list[DataRecord], properties: dict[int, Properties]):
|
||||
self._records: list[DataRecord] = records
|
||||
self._properties: dict[int, Properties] = properties
|
||||
self._bbox = BoundingBox2d()
|
||||
|
||||
def __copy__(self) -> Self:
|
||||
"""Returns a new :class:`Player` instance with a copy of recordings."""
|
||||
records = copy.deepcopy(self._records)
|
||||
player = self.__class__(records, self._properties)
|
||||
player._bbox = self._bbox.copy()
|
||||
return player
|
||||
|
||||
copy = __copy__
|
||||
|
||||
def recordings(self) -> Iterator[tuple[RecordType, Properties, Any]]:
|
||||
"""Yields all recordings as `(RecordType, Properties, Data)` tuples.
|
||||
|
||||
The content of the `Data` field is determined by the enum :class:`RecordType`:
|
||||
|
||||
- :attr:`RecordType.POLYLINE` returns a :class:`NumpyPoints2d` instance
|
||||
- :attr:`RecordType.FILLED_POLYGON` returns a tuple of :class:`NumpyPath2d` instances
|
||||
|
||||
"""
|
||||
props = self._properties
|
||||
for record in self._records:
|
||||
yield record.type, props[record.property_hash], record.data
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
"""Returns the bounding box of all recorded polylines and polygons as
|
||||
:class:`~ezdxf.math.BoundingBox2d`.
|
||||
"""
|
||||
if not self._bbox.has_data:
|
||||
self.update_bbox()
|
||||
return self._bbox
|
||||
|
||||
def update_bbox(self) -> None:
|
||||
points: list[Vec2] = []
|
||||
for record in self._records:
|
||||
if record.type == RecordType.POLYLINE:
|
||||
points.extend(record.data.extents())
|
||||
else:
|
||||
for path in record.data:
|
||||
points.extend(path.extents())
|
||||
self._bbox = BoundingBox2d(points)
|
||||
|
||||
def replay(self, backend: Backend) -> None:
|
||||
"""Replay the recording on another backend."""
|
||||
current_props = Properties()
|
||||
props = self._properties
|
||||
for record in self._records:
|
||||
current_props = props.get(record.property_hash, current_props)
|
||||
if record.type == RecordType.POLYLINE:
|
||||
backend.draw_polyline(current_props, record.data.vertices())
|
||||
else:
|
||||
paths = [p.to_path2d() for p in record.data]
|
||||
backend.draw_paths(
|
||||
current_props, paths, filled=record.type == RecordType.FILLED_PATHS
|
||||
)
|
||||
|
||||
def transform(self, m: Matrix44) -> None:
|
||||
"""Transforms the recordings by a transformation matrix `m` of type
|
||||
:class:`~ezdxf.math.Matrix44`.
|
||||
"""
|
||||
for record in self._records:
|
||||
if record.type == RecordType.POLYLINE:
|
||||
record.data.transform_inplace(m)
|
||||
else:
|
||||
for path in record.data:
|
||||
path.transform_inplace(m)
|
||||
|
||||
if self._bbox.has_data:
|
||||
# fast, but maybe inaccurate update
|
||||
self._bbox = BoundingBox2d(m.fast_2d_transform(self._bbox.rect_vertices()))
|
||||
|
||||
def sort_filled_paths(self) -> None:
|
||||
"""Sort filled paths by descending luminance (from light to dark).
|
||||
|
||||
This also changes the plot order in the way that all filled paths are plotted
|
||||
before polylines and outline paths.
|
||||
"""
|
||||
fillings = []
|
||||
outlines = []
|
||||
current = Properties()
|
||||
props = self._properties
|
||||
for record in self._records:
|
||||
if record.type == RecordType.FILLED_PATHS:
|
||||
current = props.get(record.property_hash, current)
|
||||
key = colors.luminance(current.resolve_fill_color())
|
||||
fillings.append((key, record))
|
||||
else:
|
||||
outlines.append(record)
|
||||
|
||||
fillings.sort(key=lambda r: r[0], reverse=True)
|
||||
records = [sort_rec[1] for sort_rec in fillings]
|
||||
records.extend(outlines)
|
||||
self._records = records
|
||||
|
||||
|
||||
def placement_matrix(
|
||||
bbox: BoundingBox2d, sx: float = 1.0, sy: float = 1.0, rotation: float = 0.0
|
||||
) -> Matrix44:
|
||||
"""Returns a matrix to place the bbox in the first quadrant of the coordinate
|
||||
system (+x, +y).
|
||||
"""
|
||||
if abs(sx) < 1e-9:
|
||||
sx = 1.0
|
||||
if abs(sy) < 1e-9:
|
||||
sy = 1.0
|
||||
m = Matrix44.scale(sx, sy, 1.0)
|
||||
if rotation:
|
||||
m @= Matrix44.z_rotate(math.radians(rotation))
|
||||
corners = m.fast_2d_transform(bbox.rect_vertices())
|
||||
tx, ty = BoundingBox2d(corners).extmin
|
||||
return m @ Matrix44.translate(-tx, -ty, 0)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
#
|
||||
# Central import location of frontend dependencies.
|
||||
# To extract te hpgl2 add-on from the ezdxf package, the following tools have to be
|
||||
# implemented or extracted too. The dependencies of the implemented backends are not
|
||||
# listed here.
|
||||
|
||||
from ezdxf.math import Vec2, ConstructionCircle, BoundingBox2d, Bezier4P, AnyVec, Matrix44
|
||||
from ezdxf.path import Path, bbox as path_bbox, transform_paths
|
||||
from ezdxf.tools.standards import PAGE_SIZES
|
||||
from ezdxf import colors
|
||||
NULLVEC2 = Vec2(0, 0)
|
||||
@@ -0,0 +1,458 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterator, Iterable
|
||||
import string
|
||||
|
||||
from .deps import Vec2, NULLVEC2
|
||||
from .properties import RGB
|
||||
from .plotter import Plotter
|
||||
from .tokenizer import Command, pe_decode
|
||||
|
||||
|
||||
class Interpreter:
|
||||
"""The :class:`Interpreter` is the frontend for the :class:`Plotter` class.
|
||||
The :meth:`run` methods interprets the low level HPGL commands from the
|
||||
:func:`hpgl2_commands` parser and sends the commands to the virtual plotter
|
||||
device, which sends his output to a low level :class:`Backend` class.
|
||||
|
||||
Most CAD application send a very restricted subset of commands to plotters,
|
||||
mostly just polylines and filled polygons. Implementing the whole HPGL/2 command set
|
||||
is not worth the effort - unless reality proofs otherwise.
|
||||
|
||||
Not implemented commands:
|
||||
|
||||
- the whole character group - text is send as filled polygons or polylines
|
||||
- configuration group: IN, DF, RO, IW - the plotter is initialized by creating a
|
||||
new plotter and page rotation is handled by the add-on itself
|
||||
- polygon group: EA, ER, EW, FA, RR, WG, the rectangle and wedge commands
|
||||
- line and fill attributes group: LA, RF, SM, SV, TR, UL, WU, linetypes and
|
||||
hatch patterns are decomposed into simple lines by CAD applications
|
||||
|
||||
Args:
|
||||
plotter: virtual :class:`Plotter` device
|
||||
|
||||
"""
|
||||
def __init__(self, plotter: Plotter) -> None:
|
||||
self.errors: list[str] = []
|
||||
self.not_implemented_commands: set[str] = set()
|
||||
self._disabled_commands: set[str] = set()
|
||||
self.plotter = plotter
|
||||
|
||||
def add_error(self, error: str) -> None:
|
||||
self.errors.append(error)
|
||||
|
||||
def run(self, commands: list[Command]) -> None:
|
||||
"""Interprets the low level HPGL commands from the :func:`hpgl2_commands` parser
|
||||
and sends the commands to the virtual plotter device.
|
||||
"""
|
||||
for name, args in commands:
|
||||
if name in self._disabled_commands:
|
||||
continue
|
||||
method = getattr(self, f"cmd_{name.lower()}", None)
|
||||
if method:
|
||||
method(args)
|
||||
elif name[0] in string.ascii_letters:
|
||||
self.not_implemented_commands.add(name)
|
||||
|
||||
def disable_commands(self, commands: Iterable[str]) -> None:
|
||||
"""Disable commands manually, like the scaling command ["SC", "IP", "IR"].
|
||||
This is a feature for experts, because disabling commands which changes the pen
|
||||
location may distort or destroy the plotter output.
|
||||
"""
|
||||
self._disabled_commands.update(commands)
|
||||
|
||||
# Configure pens, line types, fill types
|
||||
def cmd_ft(self, args: list[bytes]):
|
||||
"""Set fill type."""
|
||||
fill_type = 1
|
||||
spacing = 0.0
|
||||
angle = 0.0
|
||||
values = tuple(to_floats(args))
|
||||
arg_count = len(values)
|
||||
if arg_count > 0:
|
||||
fill_type = int(values[0])
|
||||
if arg_count > 1:
|
||||
spacing = values[1]
|
||||
if arg_count > 2:
|
||||
angle = values[2]
|
||||
self.plotter.set_fill_type(fill_type, spacing, angle)
|
||||
|
||||
def cmd_pc(self, args: list[bytes]):
|
||||
"""Set pen color as RGB tuple."""
|
||||
values = list(to_ints(args))
|
||||
if len(values) == 4:
|
||||
index, r, g, b = values
|
||||
self.plotter.set_pen_color(index, RGB(r, g, b))
|
||||
else:
|
||||
self.add_error("invalid arguments for PC command")
|
||||
|
||||
def cmd_pw(self, args: list[bytes]):
|
||||
"""Set pen width."""
|
||||
arg_count = len(args)
|
||||
if arg_count:
|
||||
width = to_float(args[0], 0.35)
|
||||
else:
|
||||
self.add_error("invalid arguments for PW command")
|
||||
return
|
||||
index = -1
|
||||
if arg_count > 1:
|
||||
index = to_int(args[1], index)
|
||||
self.plotter.set_pen_width(index, width)
|
||||
|
||||
def cmd_sp(self, args: list[bytes]):
|
||||
"""Select pen."""
|
||||
if len(args):
|
||||
self.plotter.set_current_pen(to_int(args[0], 1))
|
||||
|
||||
def cmd_np(self, args: list[bytes]):
|
||||
"""Set number of pens."""
|
||||
if len(args):
|
||||
self.plotter.set_max_pen_count(to_int(args[0], 2))
|
||||
|
||||
def cmd_ip(self, args: list[bytes]):
|
||||
"""Set input points p1 and p2 absolute."""
|
||||
if len(args) == 0:
|
||||
self.plotter.reset_scaling()
|
||||
return
|
||||
|
||||
points = to_points(to_floats(args))
|
||||
if len(points) > 1:
|
||||
self.plotter.set_scaling_points(points[0], points[1])
|
||||
else:
|
||||
self.add_error("invalid arguments for IP command")
|
||||
|
||||
def cmd_ir(self, args: list[bytes]):
|
||||
"""Set input points p1 and p2 in percentage of page size."""
|
||||
if len(args) == 0:
|
||||
self.plotter.reset_scaling()
|
||||
return
|
||||
|
||||
values = list(to_floats(args))
|
||||
if len(values) == 2:
|
||||
xp1 = clamp(values[0], 0.0, 100.0)
|
||||
yp1 = clamp(values[1], 0.0, 100.0)
|
||||
self.plotter.set_scaling_points_relative_1(xp1 / 100.0, yp1 / 100.0)
|
||||
elif len(values) == 4:
|
||||
xp1 = clamp(values[0], 0.0, 100.0)
|
||||
yp1 = clamp(values[1], 0.0, 100.0)
|
||||
xp2 = clamp(values[2], 0.0, 100.0)
|
||||
yp2 = clamp(values[3], 0.0, 100.0)
|
||||
self.plotter.set_scaling_points_relative_2(
|
||||
xp1 / 100.0, yp1 / 100.0, xp2 / 100.0, yp2 / 100.0
|
||||
)
|
||||
else:
|
||||
self.add_error("invalid arguments for IP command")
|
||||
|
||||
def cmd_sc(self, args: list[bytes]):
|
||||
if len(args) == 0:
|
||||
self.plotter.reset_scaling()
|
||||
return
|
||||
values = list(to_floats(args))
|
||||
if len(values) < 4:
|
||||
self.add_error("invalid arguments for SC command")
|
||||
return
|
||||
scaling_type = 0
|
||||
if len(values) > 4:
|
||||
scaling_type = int(values[4])
|
||||
if scaling_type == 1: # isotropic
|
||||
left = 50.0
|
||||
if len(values) > 5:
|
||||
left = clamp(values[5], 0.0, 100.0)
|
||||
bottom = 50.0
|
||||
if len(values) > 6:
|
||||
bottom = clamp(values[6], 0.0, 100.0)
|
||||
self.plotter.set_isotropic_scaling(
|
||||
values[0],
|
||||
values[1],
|
||||
values[2],
|
||||
values[3],
|
||||
left,
|
||||
bottom,
|
||||
)
|
||||
elif scaling_type == 2: # point factor
|
||||
self.plotter.set_point_factor(
|
||||
Vec2(values[0], values[2]), values[1], values[3]
|
||||
)
|
||||
else: # anisotropic
|
||||
self.plotter.set_anisotropic_scaling(
|
||||
values[0], values[1], values[2], values[3]
|
||||
)
|
||||
|
||||
def cmd_mc(self, args: list[bytes]):
|
||||
status = 0
|
||||
if len(args):
|
||||
status = to_int(args[0], status)
|
||||
self.plotter.set_merge_control(bool(status))
|
||||
|
||||
def cmd_ps(self, args: list[bytes]):
|
||||
length = 1189 # A0
|
||||
height = 841
|
||||
count = len(args)
|
||||
if count:
|
||||
length = to_int(args[0], length)
|
||||
height = int(length * 1.5)
|
||||
if count > 1:
|
||||
height = to_int(args[1], height)
|
||||
self.plotter.setup_page(length, height)
|
||||
|
||||
# pen movement:
|
||||
def cmd_pd(self, args: list[bytes]):
|
||||
"""Lower pen down and plot lines."""
|
||||
self.plotter.pen_down()
|
||||
if len(args):
|
||||
self.plotter.plot_polyline(to_points(to_floats(args)))
|
||||
|
||||
def cmd_pu(self, args: list[bytes]):
|
||||
"""Lift pen up and move pen."""
|
||||
self.plotter.pen_up()
|
||||
if len(args):
|
||||
self.plotter.plot_polyline(to_points(to_floats(args)))
|
||||
|
||||
def cmd_pa(self, args: list[bytes]):
|
||||
"""Place pen absolute. Plots polylines if pen is down."""
|
||||
self.plotter.set_absolute_mode()
|
||||
if len(args):
|
||||
self.plotter.plot_polyline(to_points(to_floats(args)))
|
||||
|
||||
def cmd_pr(self, args: list[bytes]):
|
||||
"""Place pen relative.Plots polylines if pen is down."""
|
||||
self.plotter.set_relative_mode()
|
||||
if len(args):
|
||||
self.plotter.plot_polyline(to_points(to_floats(args)))
|
||||
|
||||
# plot commands:
|
||||
def cmd_ci(self, args: list[bytes]):
|
||||
"""Plot full circle."""
|
||||
arg_count = len(args)
|
||||
if not arg_count:
|
||||
self.add_error("invalid arguments for CI command")
|
||||
return
|
||||
self.plotter.push_pen_state()
|
||||
# implicit pen down!
|
||||
self.plotter.pen_down()
|
||||
radius = to_float(args[0], 1.0)
|
||||
chord_angle = 5.0
|
||||
if arg_count > 1:
|
||||
chord_angle = to_float(args[1], chord_angle)
|
||||
self.plotter.plot_abs_circle(radius, chord_angle)
|
||||
self.plotter.pop_pen_state()
|
||||
|
||||
def cmd_aa(self, args: list[bytes]):
|
||||
"""Plot arc absolute."""
|
||||
if len(args) < 3:
|
||||
self.add_error("invalid arguments for AR command")
|
||||
return
|
||||
self._arc_out(args, self.plotter.plot_abs_arc)
|
||||
|
||||
def cmd_ar(self, args: list[bytes]):
|
||||
"""Plot arc relative."""
|
||||
if len(args) < 3:
|
||||
self.add_error("invalid arguments for AR command")
|
||||
return
|
||||
self._arc_out(args, self.plotter.plot_rel_arc)
|
||||
|
||||
@staticmethod
|
||||
def _arc_out(args: list[bytes], output_method):
|
||||
"""Plot arc"""
|
||||
arg_count = len(args)
|
||||
if arg_count < 3:
|
||||
return
|
||||
x = to_float(args[0])
|
||||
y = to_float(args[1])
|
||||
sweep_angle = to_float(args[2])
|
||||
chord_angle = 5.0
|
||||
if arg_count > 3:
|
||||
chord_angle = to_float(args[3], chord_angle)
|
||||
output_method(Vec2(x, y), sweep_angle, chord_angle)
|
||||
|
||||
def cmd_at(self, args: list[bytes]):
|
||||
"""Plot arc absolute from three points."""
|
||||
if len(args) < 4:
|
||||
self.add_error("invalid arguments for AT command")
|
||||
return
|
||||
self._arc_3p_out(args, self.plotter.plot_abs_arc_three_points)
|
||||
|
||||
def cmd_rt(self, args: list[bytes]):
|
||||
"""Plot arc relative from three points."""
|
||||
if len(args) < 4:
|
||||
self.add_error("invalid arguments for RT command")
|
||||
return
|
||||
self._arc_3p_out(args, self.plotter.plot_rel_arc_three_points)
|
||||
|
||||
@staticmethod
|
||||
def _arc_3p_out(args: list[bytes], output_method):
|
||||
"""Plot arc from three points"""
|
||||
arg_count = len(args)
|
||||
if arg_count < 4:
|
||||
return
|
||||
points = to_points(to_floats(args))
|
||||
if len(points) < 2:
|
||||
return
|
||||
chord_angle = 5.0
|
||||
if arg_count > 4:
|
||||
chord_angle = to_float(args[4], chord_angle)
|
||||
try:
|
||||
output_method(points[0], points[1], chord_angle)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
def cmd_bz(self, args: list[bytes]):
|
||||
"""Plot cubic Bezier curves with absolute user coordinates."""
|
||||
self._bezier_out(args, self.plotter.plot_abs_cubic_bezier)
|
||||
|
||||
def cmd_br(self, args: list[bytes]):
|
||||
"""Plot cubic Bezier curves with relative user coordinates."""
|
||||
self._bezier_out(args, self.plotter.plot_rel_cubic_bezier)
|
||||
|
||||
@staticmethod
|
||||
def _bezier_out(args: list[bytes], output_method):
|
||||
kind = 0
|
||||
ctrl1 = NULLVEC2
|
||||
ctrl2 = NULLVEC2
|
||||
for point in to_points(to_floats(args)):
|
||||
if kind == 0:
|
||||
ctrl1 = point
|
||||
elif kind == 1:
|
||||
ctrl2 = point
|
||||
elif kind == 2:
|
||||
end = point
|
||||
output_method(ctrl1, ctrl2, end)
|
||||
kind = (kind + 1) % 3
|
||||
|
||||
def cmd_pe(self, args: list[bytes]):
|
||||
"""Plot Polyline Encoded."""
|
||||
if len(args):
|
||||
data = args[0]
|
||||
else:
|
||||
self.add_error("invalid arguments for PE command")
|
||||
return
|
||||
|
||||
plotter = self.plotter
|
||||
# The last pen up/down state remains after leaving the PE command.
|
||||
pen_down = True
|
||||
# Ignores and preserves the current absolute/relative mode of the plotter.
|
||||
absolute = False
|
||||
frac_bin_bits = 0
|
||||
base = 64
|
||||
index = 0
|
||||
length = len(data)
|
||||
point_queue: list[Vec2] = []
|
||||
|
||||
while index < length:
|
||||
char = data[index]
|
||||
if char in b":<>=7":
|
||||
index += 1
|
||||
if char == 58: # ":" - select pen
|
||||
values, index = pe_decode(data, base=base, start=index)
|
||||
plotter.set_current_pen(int(values[0]))
|
||||
if len(values) > 1:
|
||||
point_queue.extend(to_points(values[1:]))
|
||||
elif char == 60: # "<" - pen up and goto coordinates
|
||||
pen_down = False
|
||||
elif char == 62: # ">" - fractional data
|
||||
values, index = pe_decode(data, base=base, start=index)
|
||||
frac_bin_bits = int(values[0])
|
||||
if len(values) > 1:
|
||||
point_queue.extend(to_points(values[1:]))
|
||||
elif char == 61: # "=" - next coordinates are absolute
|
||||
absolute = True
|
||||
elif char == 55: # "7" - 7-bit mode
|
||||
base = 32
|
||||
else:
|
||||
values, index = pe_decode(
|
||||
data, frac_bits=frac_bin_bits, base=base, start=index
|
||||
)
|
||||
point_queue.extend(to_points(values))
|
||||
|
||||
if point_queue:
|
||||
plotter.pen_down()
|
||||
if absolute:
|
||||
# next point is absolute: make relative
|
||||
point_queue[0] = point_queue[0] - plotter.user_location
|
||||
if not pen_down:
|
||||
target = point_queue.pop(0)
|
||||
plotter.move_to_rel(target)
|
||||
if not point_queue: # last point in queue
|
||||
plotter.pen_up()
|
||||
if point_queue:
|
||||
plotter.plot_rel_polyline(point_queue)
|
||||
point_queue.clear()
|
||||
pen_down = True
|
||||
absolute = False
|
||||
|
||||
# polygon mode:
|
||||
def cmd_pm(self, args: list[bytes]) -> None:
|
||||
"""Enter/Exit polygon mode."""
|
||||
status = 0
|
||||
if len(args):
|
||||
status = to_int(args[0], status)
|
||||
if status == 2:
|
||||
self.plotter.exit_polygon_mode()
|
||||
else:
|
||||
self.plotter.enter_polygon_mode(status)
|
||||
|
||||
def cmd_fp(self, args: list[bytes]) -> None:
|
||||
"""Plot filled polygon."""
|
||||
fill_method = 0
|
||||
if len(args):
|
||||
fill_method = one_of(to_int(args[0], fill_method), (0, 1))
|
||||
self.plotter.fill_polygon(fill_method)
|
||||
|
||||
def cmd_ep(self, _) -> None:
|
||||
"""Plot edged polygon."""
|
||||
self.plotter.edge_polygon()
|
||||
|
||||
|
||||
def to_floats(args: Iterable[bytes]) -> Iterator[float]:
|
||||
for arg in args:
|
||||
try:
|
||||
yield float(arg)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def to_ints(args: Iterable[bytes]) -> Iterator[int]:
|
||||
for arg in args:
|
||||
try:
|
||||
yield int(arg)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def to_points(values: Iterable[float]) -> list[Vec2]:
|
||||
points: list[Vec2] = []
|
||||
append_point = False
|
||||
buffer: float = 0.0
|
||||
for value in values:
|
||||
if append_point:
|
||||
points.append(Vec2(buffer, value))
|
||||
append_point = False
|
||||
else:
|
||||
buffer = value
|
||||
append_point = True
|
||||
return points
|
||||
|
||||
|
||||
def to_float(s: bytes, default=0.0) -> float:
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def to_int(s: bytes, default=0) -> int:
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def clamp(v, v_min, v_max):
|
||||
return max(min(v_max, v), v_min)
|
||||
|
||||
|
||||
def one_of(value, choice):
|
||||
if value in choice:
|
||||
return value
|
||||
return choice[0]
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# 1 plot unit (plu) = 0.025mm
|
||||
# 40 plu = 1 mm
|
||||
# 1016 plu = 1 inch
|
||||
# 3.39 plu = 1 dot @300 dpi
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Sequence
|
||||
import math
|
||||
from .deps import Vec2, NULLVEC2
|
||||
|
||||
INCH_TO_PLU = 1016
|
||||
MM_TO_PLU = 40
|
||||
|
||||
|
||||
class Page:
|
||||
def __init__(self, size_x: int, size_y: int):
|
||||
self.size_x = int(size_x) # in plotter units (plu)
|
||||
self.size_y = int(size_y) # plu
|
||||
|
||||
self.p1 = NULLVEC2 # plu
|
||||
self.p2 = Vec2(size_x, size_y)
|
||||
self.user_scaling = False
|
||||
self.user_scale_x: float = 1.0
|
||||
self.user_scale_y: float = 1.0
|
||||
self.user_origin = NULLVEC2 # plu
|
||||
|
||||
def set_scaling_points(self, p1: Vec2, p2: Vec2) -> None:
|
||||
self.reset_scaling()
|
||||
self.p1 = Vec2(p1)
|
||||
self.p2 = Vec2(p2)
|
||||
|
||||
def apply_scaling_factors(self, sx: float, sy: float) -> None:
|
||||
self.set_ucs(self.user_origin, self.user_scale_x * sx, self.user_scale_y * sy)
|
||||
|
||||
def set_scaling_points_relative_1(self, xp1: float, yp1: float) -> None:
|
||||
size = self.p2 - self.p1
|
||||
p1 = Vec2(self.size_x * xp1, self.size_y * yp1)
|
||||
self.set_scaling_points(p1, p1 + size)
|
||||
|
||||
def set_scaling_points_relative_2(
|
||||
self, xp1: float, yp1: float, xp2: float, yp2: float
|
||||
) -> None:
|
||||
p1 = Vec2(self.size_x * xp1, self.size_y * yp1)
|
||||
p2 = Vec2(self.size_x * xp2, self.size_y * yp2)
|
||||
self.set_scaling_points(p1, p2)
|
||||
|
||||
def reset_scaling(self) -> None:
|
||||
self.p1 = NULLVEC2
|
||||
self.p2 = Vec2(self.size_x, self.size_y)
|
||||
self.set_ucs(NULLVEC2)
|
||||
|
||||
def set_isotropic_scaling(
|
||||
self,
|
||||
x_min: float,
|
||||
x_max: float,
|
||||
y_min: float,
|
||||
y_max: float,
|
||||
left=0.5,
|
||||
bottom=0.5,
|
||||
) -> None:
|
||||
size = self.p2 - self.p1
|
||||
delta_x = x_max - x_min
|
||||
delta_y = y_max - y_min
|
||||
scale_x = 1.0
|
||||
if abs(delta_x) > 1e-9:
|
||||
scale_x = size.x / delta_x
|
||||
scale_y = 1.0
|
||||
if abs(delta_y) > 1e-9:
|
||||
scale_y = size.y / delta_y
|
||||
|
||||
scale = min(abs(scale_x), abs(scale_y))
|
||||
scale_x = math.copysign(scale, scale_x)
|
||||
scale_y = math.copysign(scale, scale_y)
|
||||
offset_x = (size.x - delta_x * scale_x) * left
|
||||
offset_y = (size.y - delta_y * scale_y) * bottom
|
||||
origin_x = self.p1.x + offset_x - x_min * scale_x
|
||||
origin_y = self.p1.y + offset_y - y_min * scale_y
|
||||
self.set_ucs(Vec2(origin_x, origin_y), scale_x, scale_y)
|
||||
|
||||
def set_anisotropic_scaling(
|
||||
self, x_min: float, x_max: float, y_min: float, y_max: float
|
||||
) -> None:
|
||||
size = self.p2 - self.p1
|
||||
delta_x = x_max - x_min
|
||||
delta_y = y_max - y_min
|
||||
scale_x = 1.0
|
||||
if abs(delta_x) > 1e-9:
|
||||
scale_x = size.x / delta_x
|
||||
scale_y = 1.0
|
||||
if abs(delta_y) > 1e-9:
|
||||
scale_y = size.y / delta_y
|
||||
origin_x = self.p1.x - x_min * scale_x
|
||||
origin_y = self.p1.y - y_min * scale_y
|
||||
self.set_ucs(Vec2(origin_x, origin_y), scale_x, scale_y)
|
||||
|
||||
def set_ucs(self, origin: Vec2, sx: float = 1.0, sy: float = 1.0):
|
||||
self.user_origin = Vec2(origin)
|
||||
self.user_scale_x = float(sx)
|
||||
self.user_scale_y = float(sy)
|
||||
if abs(self.user_scale_x) < 1e-6:
|
||||
self.user_scale_x = 1.0
|
||||
if abs(self.user_scale_y) < 1e-6:
|
||||
self.user_scale_y = 1.0
|
||||
if math.isclose(self.user_scale_x, 1.0) and math.isclose(
|
||||
self.user_scale_y, 1.0
|
||||
):
|
||||
self.user_scaling = False
|
||||
else:
|
||||
self.user_scaling = True
|
||||
|
||||
def page_point(self, x: float, y: float) -> Vec2:
|
||||
"""Returns the page location as page point in plotter units."""
|
||||
return self.page_vector(x, y) + self.user_origin
|
||||
|
||||
def page_vector(self, x: float, y: float) -> Vec2:
|
||||
"""Returns the user vector in page vector in plotter units."""
|
||||
if self.user_scaling:
|
||||
x = self.user_scale_x * x
|
||||
y = self.user_scale_y * y
|
||||
return Vec2(x, y)
|
||||
|
||||
def page_points(self, points: Sequence[Vec2]) -> list[Vec2]:
|
||||
"""Returns all user points as page points in plotter units."""
|
||||
return [self.page_point(p.x, p.y) for p in points]
|
||||
|
||||
def page_vectors(self, vectors: Sequence[Vec2]) -> list[Vec2]:
|
||||
"""Returns all user vectors as page vectors in plotter units."""
|
||||
return [self.page_vector(p.x, p.y) for p in vectors]
|
||||
|
||||
def scale_length(self, length: float) -> tuple[float, float]:
|
||||
"""Scale a length in user units to plotter units, scaling can be non-uniform."""
|
||||
return length * self.user_scale_x, length * self.user_scale_y
|
||||
@@ -0,0 +1,315 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, Iterator
|
||||
import math
|
||||
|
||||
from .deps import (
|
||||
Vec2,
|
||||
Path,
|
||||
NULLVEC2,
|
||||
ConstructionCircle,
|
||||
Bezier4P,
|
||||
)
|
||||
from .properties import RGB, Properties, FillType
|
||||
from .backend import Backend
|
||||
from .polygon_buffer import PolygonBuffer
|
||||
from .page import Page
|
||||
|
||||
|
||||
class Plotter:
|
||||
"""
|
||||
The :class:`Plotter` class represents a virtual plotter device.
|
||||
|
||||
The HPGL/2 commands send by the :class:`Interpreter` are processed into simple
|
||||
polylines and filled polygons and send to low level :class:`Backend`.
|
||||
|
||||
HPGL/2 uses a units system called "Plot Units":
|
||||
|
||||
- 1 plot unit (plu) = 0.025mm
|
||||
- 40 plu = 1 mm
|
||||
- 1016 plu = 1 inch
|
||||
|
||||
The Plotter device does not support font rendering and page rotation (RO).
|
||||
The scaling commands IP, RP, SC are supported.
|
||||
|
||||
"""
|
||||
def __init__(self, backend: Backend) -> None:
|
||||
self.backend = backend
|
||||
self._output_backend = backend
|
||||
self._polygon_buffer = PolygonBuffer()
|
||||
self.page = Page(1189, 841)
|
||||
self.properties = Properties()
|
||||
self.is_pen_down = False
|
||||
self.is_absolute_mode = True
|
||||
self.is_polygon_mode = False
|
||||
self.has_merge_control = False
|
||||
self._user_location = NULLVEC2
|
||||
self._pen_state_stack: list[bool] = []
|
||||
|
||||
@property
|
||||
def user_location(self) -> Vec2:
|
||||
"""Returns the current pen location as point in the user coordinate system."""
|
||||
return self._user_location
|
||||
|
||||
@property
|
||||
def page_location(self) -> Vec2:
|
||||
"""Returns the current pen location as page point in plotter units."""
|
||||
location = self.user_location
|
||||
return self.page.page_point(location.x, location.y)
|
||||
|
||||
def setup_page(self, size_x: int, size_y: int):
|
||||
self.page = Page(size_x, size_y)
|
||||
|
||||
def set_scaling_points(self, p1: Vec2, p2: Vec2) -> None:
|
||||
self.page.set_scaling_points(p1, p2)
|
||||
|
||||
def set_scaling_points_relative_1(self, xp1: float, yp1: float) -> None:
|
||||
self.page.set_scaling_points_relative_1(xp1, yp1)
|
||||
|
||||
def set_scaling_points_relative_2(
|
||||
self, xp1: float, yp1: float, xp2: float, yp2: float
|
||||
) -> None:
|
||||
self.page.set_scaling_points_relative_2(xp1, yp1, xp2, yp2)
|
||||
|
||||
def reset_scaling(self) -> None:
|
||||
self.page.reset_scaling()
|
||||
|
||||
def set_point_factor(self, origin: Vec2, scale_x: float, scale_y: float) -> None:
|
||||
self.page.set_ucs(origin, scale_x, scale_y)
|
||||
|
||||
def set_isotropic_scaling(
|
||||
self,
|
||||
x_min: float,
|
||||
x_max: float,
|
||||
y_min: float,
|
||||
y_max: float,
|
||||
left=0.5,
|
||||
bottom=0.5,
|
||||
) -> None:
|
||||
self.page.set_isotropic_scaling(x_min, x_max, y_min, y_max, left, bottom)
|
||||
|
||||
def set_anisotropic_scaling(
|
||||
self, x_min: float, x_max: float, y_min: float, y_max: float
|
||||
) -> None:
|
||||
self.page.set_anisotropic_scaling(x_min, x_max, y_min, y_max)
|
||||
|
||||
def set_merge_control(self, status: bool) -> None:
|
||||
self.has_merge_control = status
|
||||
|
||||
def pen_up(self) -> None:
|
||||
self.is_pen_down = False
|
||||
|
||||
def pen_down(self) -> None:
|
||||
self.is_pen_down = True
|
||||
|
||||
def push_pen_state(self) -> None:
|
||||
self._pen_state_stack.append(self.is_pen_down)
|
||||
|
||||
def pop_pen_state(self) -> None:
|
||||
if len(self._pen_state_stack):
|
||||
self.is_pen_down = self._pen_state_stack.pop()
|
||||
|
||||
def move_to(self, location: Vec2) -> None:
|
||||
if self.is_absolute_mode:
|
||||
self.move_to_abs(location)
|
||||
else:
|
||||
self.move_to_rel(location)
|
||||
|
||||
def move_to_abs(self, user_location: Vec2) -> None:
|
||||
self._user_location = user_location
|
||||
|
||||
def move_to_rel(self, user_location: Vec2) -> None:
|
||||
self._user_location += user_location
|
||||
|
||||
def set_absolute_mode(self) -> None:
|
||||
self.is_absolute_mode = True
|
||||
|
||||
def set_relative_mode(self) -> None:
|
||||
self.is_absolute_mode = False
|
||||
|
||||
def set_current_pen(self, index: int) -> None:
|
||||
self.properties.set_current_pen(index)
|
||||
|
||||
def set_max_pen_count(self, index: int) -> None:
|
||||
self.properties.set_max_pen_count(index)
|
||||
|
||||
def set_pen_width(self, index: int, width: float) -> None:
|
||||
self.properties.set_pen_width(index, width)
|
||||
|
||||
def set_pen_color(self, index: int, color: RGB) -> None:
|
||||
self.properties.set_pen_color(index, color)
|
||||
|
||||
def set_fill_type(self, fill_type: int, spacing: float, angle: float) -> None:
|
||||
if fill_type in (3, 4): # adjust spacing between hatching lines
|
||||
spacing = max(self.page.scale_length(spacing))
|
||||
self.properties.set_fill_type(fill_type, spacing, angle)
|
||||
|
||||
def enter_polygon_mode(self, status: int) -> None:
|
||||
self.is_polygon_mode = True
|
||||
self.backend = self._polygon_buffer
|
||||
if status == 0:
|
||||
self._polygon_buffer.reset(self.page_location)
|
||||
elif status == 1:
|
||||
self._polygon_buffer.close_path()
|
||||
|
||||
def exit_polygon_mode(self) -> None:
|
||||
self.is_polygon_mode = False
|
||||
self._polygon_buffer.close_path()
|
||||
self.backend = self._output_backend
|
||||
|
||||
def fill_polygon(self, fill_method: int) -> None:
|
||||
self.properties.set_fill_method(fill_method)
|
||||
self.plot_filled_polygon_buffer(self._polygon_buffer.get_paths())
|
||||
|
||||
def edge_polygon(self) -> None:
|
||||
self.plot_outline_polygon_buffer(self._polygon_buffer.get_paths())
|
||||
|
||||
def plot_polyline(self, points: Sequence[Vec2]):
|
||||
if not points:
|
||||
return
|
||||
if self.is_absolute_mode:
|
||||
self.plot_abs_polyline(points)
|
||||
else:
|
||||
self.plot_rel_polyline(points)
|
||||
|
||||
def plot_abs_polyline(self, points: Sequence[Vec2]):
|
||||
# input coordinates are user coordinates
|
||||
if not points:
|
||||
return
|
||||
current_page_location = self.page_location
|
||||
self.move_to_abs(points[-1]) # user coordinates!
|
||||
if self.is_pen_down:
|
||||
# convert to page coordinates:
|
||||
points = self.page.page_points(points)
|
||||
# insert current page location as starting point:
|
||||
points.insert(0, current_page_location)
|
||||
# draw polyline in absolute page coordinates:
|
||||
self.backend.draw_polyline(self.properties, points)
|
||||
|
||||
def plot_rel_polyline(self, points: Sequence[Vec2]):
|
||||
# input coordinates are user coordinates
|
||||
if not points:
|
||||
return
|
||||
# convert to absolute user coordinates:
|
||||
self.plot_abs_polyline(
|
||||
tuple(rel_to_abs_points_dynamic(self.user_location, points))
|
||||
)
|
||||
|
||||
def plot_abs_circle(self, radius: float, chord_angle: float):
|
||||
# radius in user units
|
||||
if self.is_pen_down:
|
||||
center = self.user_location
|
||||
vertices = [
|
||||
center + Vec2.from_deg_angle(a, radius)
|
||||
for a in arc_angles(0, 360.0, chord_angle)
|
||||
]
|
||||
# draw circle in absolute page coordinates:
|
||||
self.backend.draw_polyline(self.properties, vertices)
|
||||
|
||||
def plot_abs_arc(self, center: Vec2, sweep_angle: float, chord_angle: float):
|
||||
start_point = self.user_location
|
||||
radius_vec = start_point - center
|
||||
radius = radius_vec.magnitude
|
||||
start_angle = radius_vec.angle_deg
|
||||
end_angle = start_angle + sweep_angle
|
||||
end_point = center + Vec2.from_deg_angle(end_angle, radius)
|
||||
|
||||
self.move_to_abs(end_point)
|
||||
if self.is_pen_down:
|
||||
vertices = [
|
||||
center + Vec2.from_deg_angle(a, radius)
|
||||
for a in arc_angles(start_angle, sweep_angle, chord_angle)
|
||||
]
|
||||
self.backend.draw_polyline(self.properties, vertices)
|
||||
|
||||
def plot_rel_arc(self, center_rel: Vec2, sweep_angle: float, chord_angle: float):
|
||||
self.plot_abs_arc(center_rel + self.user_location, sweep_angle, chord_angle)
|
||||
|
||||
def plot_abs_arc_three_points(self, inter: Vec2, end: Vec2, chord_angle: float):
|
||||
# input coordinates are user coordinates
|
||||
start = self.user_location
|
||||
circle = ConstructionCircle.from_3p(start, inter, end)
|
||||
center = circle.center
|
||||
start_angle = (start - center).angle_deg
|
||||
end_angle = (end - center).angle_deg
|
||||
inter_angle = (inter - center).angle_deg
|
||||
sweep_angle = sweeping_angle(start_angle, inter_angle, end_angle)
|
||||
self.plot_abs_arc(center, sweep_angle, chord_angle)
|
||||
|
||||
def plot_rel_arc_three_points(self, inter: Vec2, end: Vec2, chord_angle: float):
|
||||
# input coordinates are user coordinates
|
||||
current = self.user_location
|
||||
self.plot_abs_arc_three_points(current + inter, current + end, chord_angle)
|
||||
|
||||
def plot_abs_cubic_bezier(self, ctrl1: Vec2, ctrl2: Vec2, end: Vec2):
|
||||
# input coordinates are user coordinates
|
||||
current_page_location = self.page_location
|
||||
self.move_to_abs(end) # user coordinates!
|
||||
if self.is_pen_down:
|
||||
# convert to page coordinates:
|
||||
ctrl1, ctrl2, end = self.page.page_points((ctrl1, ctrl2, end))
|
||||
# draw cubic bezier curve in absolute page coordinates:
|
||||
p = Path(current_page_location)
|
||||
p.curve4_to(end, ctrl1, ctrl2)
|
||||
self.backend.draw_paths(self.properties, [p], filled=False)
|
||||
|
||||
def plot_rel_cubic_bezier(self, ctrl1: Vec2, ctrl2: Vec2, end: Vec2):
|
||||
# input coordinates are user coordinates
|
||||
ctrl1, ctrl2, end = rel_to_abs_points_static(
|
||||
self.user_location, (ctrl1, ctrl2, end)
|
||||
)
|
||||
self.plot_abs_cubic_bezier(ctrl1, ctrl2, end)
|
||||
|
||||
def plot_filled_polygon_buffer(self, paths: Sequence[Path]):
|
||||
# input coordinates are page coordinates!
|
||||
self.backend.draw_paths(self.properties, paths, filled=True)
|
||||
|
||||
def plot_outline_polygon_buffer(self, paths: Sequence[Path]):
|
||||
# input coordinates are page coordinates!
|
||||
self.backend.draw_paths(self.properties, paths, filled=False)
|
||||
|
||||
|
||||
def rel_to_abs_points_dynamic(current: Vec2, points: Sequence[Vec2]) -> Iterator[Vec2]:
|
||||
"""Returns the absolute location of increment points, each point is an increment
|
||||
of the previous point starting at the current pen location.
|
||||
"""
|
||||
for point in points:
|
||||
current += point
|
||||
yield current
|
||||
|
||||
|
||||
def rel_to_abs_points_static(current: Vec2, points: Sequence[Vec2]) -> Iterator[Vec2]:
|
||||
"""Returns the absolute location of increment points, all points are relative
|
||||
to the current pen location.
|
||||
"""
|
||||
for point in points:
|
||||
yield current + point
|
||||
|
||||
|
||||
def arc_angles(start: float, sweep_angle: float, chord_angle: float) -> Iterator[float]:
|
||||
# clamp to 0.5 .. 180
|
||||
chord_angle = min(180.0, max(0.5, chord_angle))
|
||||
count = abs(round(sweep_angle / chord_angle))
|
||||
delta = sweep_angle / count
|
||||
for index in range(count + 1):
|
||||
yield start + delta * index
|
||||
|
||||
|
||||
def sweeping_angle(start: float, intermediate: float, end: float) -> float:
|
||||
"""Returns the sweeping angle from start angle to end angle passing the
|
||||
intermediate angle.
|
||||
"""
|
||||
start = start % 360.0
|
||||
intermediate = intermediate % 360.0
|
||||
end = end % 360.0
|
||||
angle = end - start
|
||||
i_to_s = start - intermediate
|
||||
i_to_e = end - intermediate
|
||||
if math.isclose(abs(i_to_e) + abs(i_to_s), abs(angle)):
|
||||
return angle
|
||||
else: # return complementary angle with opposite orientation
|
||||
if angle < 0:
|
||||
return 360.0 + angle
|
||||
else:
|
||||
return angle - 360.0
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence
|
||||
from .backend import Backend
|
||||
from .deps import Vec2, Path
|
||||
from .properties import Properties
|
||||
|
||||
|
||||
class PolygonBuffer(Backend):
|
||||
def __init__(self):
|
||||
self.path = Path()
|
||||
self.start_new_sub_polygon = False
|
||||
|
||||
def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None:
|
||||
if len(points) == 0:
|
||||
return
|
||||
index = 0
|
||||
if self.start_new_sub_polygon:
|
||||
self.start_new_sub_polygon = False
|
||||
count = len(points)
|
||||
while self.path.end.isclose(points[index]):
|
||||
index += 1
|
||||
if index == count:
|
||||
return
|
||||
self.path.move_to(points[index])
|
||||
for p in points[index + 1 :]:
|
||||
self.path.line_to(p)
|
||||
|
||||
def draw_paths(
|
||||
self, properties: Properties, paths: Sequence[Path], filled: bool
|
||||
) -> None:
|
||||
if filled:
|
||||
raise NotImplementedError()
|
||||
for p in paths:
|
||||
if len(p) == 0:
|
||||
continue
|
||||
if self.start_new_sub_polygon:
|
||||
self.start_new_sub_polygon = False
|
||||
self.path.move_to(p.start)
|
||||
self.path.append_path(p)
|
||||
|
||||
def get_paths(self) -> Sequence[Path]:
|
||||
return list(self.path.sub_paths())
|
||||
|
||||
def close_path(self):
|
||||
if len(self.path):
|
||||
self.path.close_sub_path()
|
||||
self.start_new_sub_polygon = True
|
||||
|
||||
def reset(self, location: Vec2) -> None:
|
||||
self.path = Path(location)
|
||||
self.start_new_sub_polygon = False
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import enum
|
||||
import copy
|
||||
from ezdxf.colors import RGB
|
||||
|
||||
|
||||
class FillType(enum.IntEnum):
|
||||
"""Fill type enumeration."""
|
||||
|
||||
NONE = 0
|
||||
SOLID = 1
|
||||
HATCHING = 2
|
||||
CROSS_HATCHING = 3
|
||||
SHADING = 4
|
||||
|
||||
|
||||
class FillMethod(enum.IntEnum):
|
||||
"""Fill method enumeration."""
|
||||
|
||||
EVEN_ODD = 0
|
||||
NON_ZERO_WINDING = 1
|
||||
|
||||
|
||||
RGB_NONE = RGB(-1, -1, -1)
|
||||
RGB_BLACK = RGB(0, 0, 0)
|
||||
RGB_WHITE = RGB(255, 255, 255)
|
||||
LIGHT_GREY = RGB(200, 200, 200)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Pen:
|
||||
"""Represents a pen table entry."""
|
||||
|
||||
index: int
|
||||
width: float # in mm
|
||||
color: RGB
|
||||
|
||||
|
||||
class Properties:
|
||||
"""Consolidated display properties."""
|
||||
|
||||
DEFAULT_PEN = Pen(1, 0.35, RGB_NONE)
|
||||
|
||||
def __init__(self) -> None:
|
||||
# hashed content
|
||||
self.pen_index: int = 1
|
||||
self.pen_color = RGB_NONE
|
||||
self.pen_width: float = 0.35 # in mm
|
||||
self.fill_type = FillType.SOLID
|
||||
self.fill_method = FillMethod.EVEN_ODD
|
||||
self.fill_hatch_line_angle: float = 0.0 # in degrees
|
||||
self.fill_hatch_line_spacing: float = 40.0 # in plotter units
|
||||
self.fill_shading_density: float = 100.0
|
||||
# not hashed content
|
||||
self.max_pen_count: int = 2
|
||||
self.pen_table: dict[int, Pen] = {}
|
||||
self.reset()
|
||||
|
||||
def hash(self) -> int:
|
||||
return hash(
|
||||
(
|
||||
self.pen_index,
|
||||
self.pen_color,
|
||||
self.pen_width,
|
||||
self.fill_type,
|
||||
self.fill_method,
|
||||
self.fill_hatch_line_angle,
|
||||
self.fill_hatch_line_spacing,
|
||||
self.fill_shading_density,
|
||||
)
|
||||
)
|
||||
|
||||
def copy(self) -> Properties:
|
||||
# the pen table is shared across all copies of Properties
|
||||
return copy.copy(self)
|
||||
|
||||
def setup_default_pen_table(self):
|
||||
if len(self.pen_table):
|
||||
return
|
||||
pens = self.pen_table
|
||||
width = self.DEFAULT_PEN.width
|
||||
pens[0] = Pen(0, width, RGB(255, 255, 255)) # white
|
||||
pens[1] = Pen(1, width, RGB(0, 0, 0)) # black
|
||||
pens[2] = Pen(2, width, RGB(255, 0, 0)) # red
|
||||
pens[3] = Pen(3, width, RGB(0, 255, 0)) # green
|
||||
pens[4] = Pen(4, width, RGB(255, 255, 0)) # yellow
|
||||
pens[5] = Pen(5, width, RGB(0, 0, 255)) # blue
|
||||
pens[6] = Pen(6, width, RGB(255, 0, 255)) # magenta
|
||||
pens[7] = Pen(6, width, RGB(0, 255, 255)) # cyan
|
||||
|
||||
def reset(self) -> None:
|
||||
self.max_pen_count = 2
|
||||
self.pen_index = self.DEFAULT_PEN.index
|
||||
self.pen_color = self.DEFAULT_PEN.color
|
||||
self.pen_width = self.DEFAULT_PEN.width
|
||||
self.pen_table = {}
|
||||
self.fill_type = FillType.SOLID
|
||||
self.fill_method = FillMethod.EVEN_ODD
|
||||
self.fill_hatch_line_angle = 0.0
|
||||
self.fill_hatch_line_spacing = 40.0
|
||||
self.fill_shading_density = 1.0
|
||||
self.setup_default_pen_table()
|
||||
|
||||
def get_pen(self, index: int) -> Pen:
|
||||
return self.pen_table.get(index, self.DEFAULT_PEN)
|
||||
|
||||
def set_max_pen_count(self, count: int) -> None:
|
||||
self.max_pen_count = count
|
||||
|
||||
def set_current_pen(self, index: int) -> None:
|
||||
self.pen_index = index
|
||||
pen = self.get_pen(index)
|
||||
self.pen_width = pen.width
|
||||
self.pen_color = pen.color
|
||||
|
||||
def set_pen_width(self, index: int, width: float) -> None:
|
||||
if index == -1:
|
||||
self.pen_width = width
|
||||
else:
|
||||
pen = self.pen_table.setdefault(
|
||||
index, Pen(index, width, self.DEFAULT_PEN.color)
|
||||
)
|
||||
pen.width = width
|
||||
|
||||
def set_pen_color(self, index: int, rgb: RGB) -> None:
|
||||
if index == -1:
|
||||
self.pen_color = rgb
|
||||
else:
|
||||
pen = self.pen_table.setdefault(
|
||||
index, Pen(index, self.DEFAULT_PEN.width, rgb)
|
||||
)
|
||||
pen.color = rgb
|
||||
|
||||
def set_fill_type(self, fill_type: int, spacing: float, angle: float):
|
||||
if fill_type == 3:
|
||||
self.fill_type = FillType.HATCHING
|
||||
self.fill_hatch_line_spacing = spacing
|
||||
self.fill_hatch_line_angle = angle
|
||||
elif fill_type == 4:
|
||||
self.fill_type = FillType.CROSS_HATCHING
|
||||
self.fill_hatch_line_spacing = spacing
|
||||
self.fill_hatch_line_angle = angle
|
||||
elif fill_type == 10:
|
||||
self.fill_type = FillType.SHADING
|
||||
self.fill_shading_density = spacing
|
||||
else:
|
||||
self.fill_type = FillType.SOLID
|
||||
|
||||
def set_fill_method(self, fill_method: int) -> None:
|
||||
self.fill_method = FillMethod(fill_method)
|
||||
|
||||
def resolve_pen_color(self) -> RGB:
|
||||
"""Returns the final RGB pen color."""
|
||||
rgb = self.pen_color
|
||||
if rgb is RGB_NONE:
|
||||
pen = self.pen_table.get(self.pen_index, self.DEFAULT_PEN)
|
||||
rgb = pen.color
|
||||
if rgb is RGB_NONE:
|
||||
return RGB_BLACK
|
||||
return rgb
|
||||
|
||||
def resolve_fill_color(self) -> RGB:
|
||||
"""Returns the final RGB fill color."""
|
||||
ft = self.fill_type
|
||||
if ft == FillType.SOLID:
|
||||
return self.resolve_pen_color()
|
||||
elif ft == FillType.SHADING:
|
||||
grey = min(int(2.55 * (100.0 - self.fill_shading_density)), 255)
|
||||
return RGB(grey, grey, grey)
|
||||
elif ft == FillType.HATCHING or ft == FillType.CROSS_HATCHING:
|
||||
return LIGHT_GREY
|
||||
return RGB_WHITE
|
||||
@@ -0,0 +1,224 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import NamedTuple, Sequence
|
||||
|
||||
|
||||
class Command(NamedTuple):
|
||||
name: str
|
||||
args: Sequence[bytes]
|
||||
|
||||
|
||||
ESCAPE = 27
|
||||
SEMICOLON = ord(";")
|
||||
PERCENT = ord("%")
|
||||
MINUS = ord("-")
|
||||
QUOTE_CHAR = ord('"')
|
||||
CHAR_A = ord("A")
|
||||
CHAR_B = ord("B")
|
||||
CHAR_Z = ord("Z")
|
||||
DEFAULT_TEXT_TERMINATOR = 3
|
||||
|
||||
# Enter HPGL/2 mode commands
|
||||
# b"%-0B", # ??? not documented (assumption)
|
||||
# b"%-1B", # ??? not documented (really exist)
|
||||
# b"%-2B", # ??? not documented (assumption)
|
||||
# b"%-3B", # ??? not documented (assumption)
|
||||
# b"%0B", # documented in the HPGL2 reference by HP
|
||||
# b"%1B", # documented
|
||||
# b"%2B", # documented
|
||||
# b"%3B", # documented
|
||||
|
||||
|
||||
def get_enter_hpgl2_mode_command_length(s: bytes, i: int) -> int:
|
||||
try:
|
||||
if s[i] != ESCAPE:
|
||||
return 0
|
||||
if s[i + 1] != PERCENT:
|
||||
return 0
|
||||
length = 4
|
||||
if s[i + 2] == MINUS:
|
||||
i += 1
|
||||
length = 5
|
||||
# 0, 1, 2 or 3 + "B"
|
||||
if 47 < s[i + 2] < 52 and s[i + 3] == CHAR_B:
|
||||
return length
|
||||
except IndexError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
KNOWN_START_SEQUENCES = [b"BPIN", b"BP;IN", b"INPS", b"IN;PS", b"INDF", b"IN;DF"]
|
||||
|
||||
|
||||
def has_known_start_sequence(b: bytes) -> bool:
|
||||
for start_sequence in KNOWN_START_SEQUENCES:
|
||||
if b.startswith(start_sequence):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_hpgl2_entry_point(s: bytes, start: int) -> int:
|
||||
while True:
|
||||
try:
|
||||
index = s.index(b"%", start)
|
||||
except ValueError:
|
||||
return len(s)
|
||||
length = get_enter_hpgl2_mode_command_length(s, index)
|
||||
if length:
|
||||
return index + length
|
||||
start += 2
|
||||
|
||||
|
||||
def hpgl2_commands(s: bytes) -> list[Command]:
|
||||
"""Low level plot file parser, extracts the HPGL/2 from the byte stream `b`.
|
||||
|
||||
.. Important::
|
||||
|
||||
This parser expects the "Enter HPGL/2 mode" escape sequence to recognize
|
||||
HPGL/2 commands. The sequence looks like this: ``[ESC]%1B``, multiple variants
|
||||
of this sequence are supported.
|
||||
|
||||
"""
|
||||
text_terminator = DEFAULT_TEXT_TERMINATOR
|
||||
|
||||
def find_terminator(i: int) -> int:
|
||||
while i < length:
|
||||
c = s[i]
|
||||
if c == QUOTE_CHAR:
|
||||
i = find_mark(i + 1, QUOTE_CHAR)
|
||||
elif (CHAR_A <= c <= CHAR_Z) or c == SEMICOLON or c == ESCAPE:
|
||||
break
|
||||
i += 1
|
||||
return i
|
||||
|
||||
def find_mark(i: int, mark: int) -> int:
|
||||
while i < length and s[i] != mark:
|
||||
i += 1
|
||||
return i + 1
|
||||
|
||||
def append_command(b: bytes) -> None:
|
||||
if b[:2] == b"DT":
|
||||
nonlocal text_terminator
|
||||
if len(b) > 2:
|
||||
text_terminator = b[2]
|
||||
else:
|
||||
text_terminator = DEFAULT_TEXT_TERMINATOR
|
||||
else:
|
||||
commands.append(make_command(b))
|
||||
|
||||
commands: list[Command] = []
|
||||
length = len(s)
|
||||
if has_known_start_sequence(s):
|
||||
index = 0
|
||||
else:
|
||||
index = find_hpgl2_entry_point(s, 0)
|
||||
while index < length:
|
||||
char = s[index]
|
||||
start = index
|
||||
|
||||
if char == ESCAPE:
|
||||
# HPGL/2 does not use escape sequences, whatever this sequence is,
|
||||
# HPGL/2 mode was left. Find next entry point into HPGL/2 mode:
|
||||
index = find_hpgl2_entry_point(s, index)
|
||||
continue
|
||||
|
||||
if char <= 32: # skip all white space and control chars between commands
|
||||
index += 1
|
||||
continue
|
||||
|
||||
index_plus_2 = index + 2
|
||||
if index_plus_2 >= length:
|
||||
append_command(s[index:])
|
||||
break
|
||||
|
||||
command = s[start:index_plus_2]
|
||||
|
||||
if command == b"PE":
|
||||
index = find_mark(index_plus_2, SEMICOLON)
|
||||
index -= 1 # exclude terminator ";" from command args
|
||||
elif command == b"LB":
|
||||
index = find_mark(index_plus_2, text_terminator)
|
||||
# include special terminator in command args,
|
||||
# otherwise the parser is confused
|
||||
else:
|
||||
index = find_terminator(index_plus_2)
|
||||
|
||||
append_command(s[start:index])
|
||||
if index < length and s[index] == SEMICOLON:
|
||||
index += 1
|
||||
return commands
|
||||
|
||||
|
||||
def make_command(cmd: bytes) -> Command:
|
||||
if not cmd:
|
||||
return Command("NOOP", tuple())
|
||||
name = cmd[:2].decode()
|
||||
if name == "PE":
|
||||
args = (bytes([c for c in cmd[2:] if c > 32]),)
|
||||
else:
|
||||
args = tuple(s for s in cmd[2:].split(b",")) # type: ignore
|
||||
return Command(name, args)
|
||||
|
||||
|
||||
def fractional_bits(decimal_places: int) -> int:
|
||||
return round(decimal_places * 3.33)
|
||||
|
||||
|
||||
def pe_encode(value: float, frac_bits: int = 0, base: int = 64) -> bytes:
|
||||
if frac_bits:
|
||||
value *= 1 << frac_bits
|
||||
x = round(value)
|
||||
else:
|
||||
x = round(value)
|
||||
if x >= 0:
|
||||
x *= 2
|
||||
else:
|
||||
x = abs(x) * 2 + 1
|
||||
|
||||
chars = bytearray()
|
||||
while x >= base:
|
||||
x, r = divmod(x, base)
|
||||
chars.append(63 + r)
|
||||
if base == 64:
|
||||
chars.append(191 + x)
|
||||
else:
|
||||
chars.append(95 + x)
|
||||
return bytes(chars)
|
||||
|
||||
|
||||
def pe_decode(
|
||||
s: bytes, frac_bits: int = 0, base=64, start: int = 0
|
||||
) -> tuple[list[float], int]:
|
||||
def _decode():
|
||||
factors.reverse()
|
||||
x = 0
|
||||
for f in factors:
|
||||
x = x * base + f
|
||||
factors.clear()
|
||||
if x & 1:
|
||||
x = -(x - 1)
|
||||
x = x >> 1
|
||||
return x
|
||||
|
||||
n = 1 << frac_bits
|
||||
if base == 64:
|
||||
terminator = 191
|
||||
else:
|
||||
terminator = 95
|
||||
values: list[float] = []
|
||||
factors = []
|
||||
for index in range(start, len(s)):
|
||||
value = s[index]
|
||||
if value < 63:
|
||||
return values, index
|
||||
if value >= terminator:
|
||||
factors.append(value - terminator)
|
||||
x = _decode()
|
||||
if frac_bits:
|
||||
values.append(x / n)
|
||||
else:
|
||||
values.append(float(x))
|
||||
else:
|
||||
factors.append(value - 63)
|
||||
return values, len(s)
|
||||
@@ -0,0 +1,522 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from ezdxf.addons import xplayer
|
||||
from ezdxf.addons.xqt import QtWidgets, QtGui, QtCore, QMessageBox
|
||||
from ezdxf.addons.drawing import svg, layout, pymupdf, dxf
|
||||
from ezdxf.addons.drawing.qtviewer import CADGraphicsView
|
||||
from ezdxf.addons.drawing.pyqt import PyQtPlaybackBackend
|
||||
|
||||
from . import api
|
||||
from .deps import BoundingBox2d, Matrix44, colors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.document import Drawing
|
||||
|
||||
VIEWER_NAME = "HPGL/2 Viewer"
|
||||
|
||||
|
||||
class HPGL2Widget(QtWidgets.QWidget):
|
||||
def __init__(self, view: CADGraphicsView) -> None:
|
||||
super().__init__()
|
||||
layout = QtWidgets.QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(view)
|
||||
self.setLayout(layout)
|
||||
self._view = view
|
||||
self._view.closing.connect(self.close)
|
||||
self._player: api.Player = api.Player([], {})
|
||||
self._reset_backend()
|
||||
|
||||
def _reset_backend(self) -> None:
|
||||
self._backend = PyQtPlaybackBackend()
|
||||
|
||||
@property
|
||||
def view(self) -> CADGraphicsView:
|
||||
return self._view
|
||||
|
||||
@property
|
||||
def player(self) -> api.Player:
|
||||
return self._player.copy()
|
||||
|
||||
def plot(self, data: bytes) -> None:
|
||||
self._reset_backend()
|
||||
self._player = api.record_plotter_output(data, api.MergeControl.AUTO)
|
||||
|
||||
def replay(
|
||||
self, bg_color="#ffffff", override=None, reset_view: bool = True
|
||||
) -> None:
|
||||
self._reset_backend()
|
||||
self._view.begin_loading()
|
||||
new_scene = QtWidgets.QGraphicsScene()
|
||||
self._backend.set_scene(new_scene)
|
||||
new_scene.addItem(self._bg_paper(bg_color))
|
||||
|
||||
xplayer.hpgl2_to_drawing(
|
||||
self._player, self._backend, bg_color="", override=override
|
||||
)
|
||||
self._view.end_loading(new_scene)
|
||||
self._view.buffer_scene_rect()
|
||||
if reset_view:
|
||||
self._view.fit_to_scene()
|
||||
|
||||
def _bg_paper(self, color):
|
||||
bbox = self._player.bbox()
|
||||
insert = bbox.extmin
|
||||
size = bbox.size
|
||||
rect = QtWidgets.QGraphicsRectItem(insert.x, insert.y, size.x, size.y)
|
||||
rect.setBrush(QtGui.QBrush(QtGui.QColor(color)))
|
||||
return rect
|
||||
|
||||
|
||||
SPACING = 20
|
||||
DEFAULT_DPI = 96
|
||||
COLOR_SCHEMA = [
|
||||
"Default",
|
||||
"Black on White",
|
||||
"White on Black",
|
||||
"Monochrome Light",
|
||||
"Monochrome Dark",
|
||||
"Blueprint High Contrast",
|
||||
"Blueprint Low Contrast",
|
||||
]
|
||||
|
||||
|
||||
class HPGL2Viewer(QtWidgets.QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._cad = HPGL2Widget(CADGraphicsView())
|
||||
self._view = self._cad.view
|
||||
self._player: api.Player = api.Player([], {})
|
||||
self._bbox: BoundingBox2d = BoundingBox2d()
|
||||
self._page_rotation = 0
|
||||
self._color_scheme = 0
|
||||
self._current_file = pathlib.Path()
|
||||
|
||||
self.page_size_label = QtWidgets.QLabel()
|
||||
self.png_size_label = QtWidgets.QLabel()
|
||||
self.message_label = QtWidgets.QLabel()
|
||||
self.scaling_factor_line_edit = QtWidgets.QLineEdit("1")
|
||||
self.dpi_line_edit = QtWidgets.QLineEdit(str(DEFAULT_DPI))
|
||||
|
||||
self.flip_x_check_box = QtWidgets.QCheckBox("Horizontal")
|
||||
self.flip_x_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
|
||||
self.flip_x_check_box.stateChanged.connect(self.update_view)
|
||||
|
||||
self.flip_y_check_box = QtWidgets.QCheckBox("Vertical")
|
||||
self.flip_y_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
|
||||
self.flip_y_check_box.stateChanged.connect(self.update_view)
|
||||
|
||||
self.rotation_combo_box = QtWidgets.QComboBox()
|
||||
self.rotation_combo_box.addItems(["0", "90", "180", "270"])
|
||||
self.rotation_combo_box.currentIndexChanged.connect(self.update_rotation)
|
||||
|
||||
self.color_combo_box = QtWidgets.QComboBox()
|
||||
self.color_combo_box.addItems(COLOR_SCHEMA)
|
||||
self.color_combo_box.currentIndexChanged.connect(self.update_colors)
|
||||
|
||||
self.aci_export_mode = QtWidgets.QCheckBox("ACI Export Mode")
|
||||
self.aci_export_mode.setCheckState(QtCore.Qt.CheckState.Unchecked)
|
||||
|
||||
self.export_svg_button = QtWidgets.QPushButton("Export SVG")
|
||||
self.export_svg_button.clicked.connect(self.export_svg)
|
||||
self.export_png_button = QtWidgets.QPushButton("Export PNG")
|
||||
self.export_png_button.clicked.connect(self.export_png)
|
||||
self.export_pdf_button = QtWidgets.QPushButton("Export PDF")
|
||||
self.export_pdf_button.clicked.connect(self.export_pdf)
|
||||
self.export_dxf_button = QtWidgets.QPushButton("Export DXF")
|
||||
self.export_dxf_button.clicked.connect(self.export_dxf)
|
||||
self.disable_export_buttons(True)
|
||||
|
||||
self.scaling_factor_line_edit.editingFinished.connect(self.update_sidebar)
|
||||
self.dpi_line_edit.editingFinished.connect(self.update_sidebar)
|
||||
|
||||
layout = QtWidgets.QHBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
container = QtWidgets.QWidget()
|
||||
container.setLayout(layout)
|
||||
self.setCentralWidget(container)
|
||||
|
||||
layout.addWidget(self._cad)
|
||||
sidebar = self.make_sidebar()
|
||||
layout.addWidget(sidebar)
|
||||
self.setWindowTitle(VIEWER_NAME)
|
||||
self.resize(1600, 900)
|
||||
self.show()
|
||||
|
||||
def reset_values(self):
|
||||
self.scaling_factor_line_edit.setText("1")
|
||||
self.dpi_line_edit.setText(str(DEFAULT_DPI))
|
||||
self.flip_x_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
|
||||
self.flip_y_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
|
||||
self.rotation_combo_box.setCurrentIndex(0)
|
||||
self._page_rotation = 0
|
||||
self.update_view()
|
||||
|
||||
def make_sidebar(self) -> QtWidgets.QWidget:
|
||||
sidebar = QtWidgets.QWidget()
|
||||
v_layout = QtWidgets.QVBoxLayout()
|
||||
v_layout.setContentsMargins(SPACING // 2, 0, SPACING // 2, 0)
|
||||
sidebar.setLayout(v_layout)
|
||||
|
||||
policy = QtWidgets.QSizePolicy()
|
||||
policy.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Fixed)
|
||||
sidebar.setSizePolicy(policy)
|
||||
|
||||
open_button = QtWidgets.QPushButton("Open HPGL/2 File")
|
||||
open_button.clicked.connect(self.select_plot_file)
|
||||
v_layout.addWidget(open_button)
|
||||
v_layout.addWidget(self.page_size_label)
|
||||
h_layout = QtWidgets.QHBoxLayout()
|
||||
h_layout.addWidget(QtWidgets.QLabel("Scaling Factor:"))
|
||||
h_layout.addWidget(self.scaling_factor_line_edit)
|
||||
v_layout.addLayout(h_layout)
|
||||
|
||||
h_layout = QtWidgets.QHBoxLayout()
|
||||
h_layout.addWidget(QtWidgets.QLabel("Page Rotation:"))
|
||||
h_layout.addWidget(self.rotation_combo_box)
|
||||
v_layout.addLayout(h_layout)
|
||||
|
||||
group = QtWidgets.QGroupBox("Mirror Page")
|
||||
h_layout = QtWidgets.QHBoxLayout()
|
||||
h_layout.addWidget(self.flip_x_check_box)
|
||||
h_layout.addWidget(self.flip_y_check_box)
|
||||
group.setLayout(h_layout)
|
||||
v_layout.addWidget(group)
|
||||
|
||||
h_layout = QtWidgets.QHBoxLayout()
|
||||
h_layout.addWidget(QtWidgets.QLabel("Colors:"))
|
||||
h_layout.addWidget(self.color_combo_box)
|
||||
v_layout.addLayout(h_layout)
|
||||
|
||||
v_layout.addSpacing(SPACING)
|
||||
|
||||
h_layout = QtWidgets.QHBoxLayout()
|
||||
h_layout.addWidget(QtWidgets.QLabel("DPI (PNG only):"))
|
||||
h_layout.addWidget(self.dpi_line_edit)
|
||||
v_layout.addLayout(h_layout)
|
||||
v_layout.addWidget(self.png_size_label)
|
||||
|
||||
v_layout.addWidget(self.export_png_button)
|
||||
v_layout.addWidget(self.export_svg_button)
|
||||
v_layout.addWidget(self.export_pdf_button)
|
||||
|
||||
v_layout.addSpacing(SPACING)
|
||||
|
||||
v_layout.addWidget(self.aci_export_mode)
|
||||
v_layout.addWidget(self.export_dxf_button)
|
||||
|
||||
v_layout.addSpacing(SPACING)
|
||||
|
||||
reset_button = QtWidgets.QPushButton("Reset")
|
||||
reset_button.clicked.connect(self.reset_values)
|
||||
v_layout.addWidget(reset_button)
|
||||
|
||||
v_layout.addSpacing(SPACING)
|
||||
|
||||
v_layout.addWidget(self.message_label)
|
||||
return sidebar
|
||||
|
||||
def disable_export_buttons(self, disabled: bool):
|
||||
self.export_svg_button.setDisabled(disabled)
|
||||
self.export_dxf_button.setDisabled(disabled)
|
||||
if pymupdf.is_pymupdf_installed:
|
||||
self.export_png_button.setDisabled(disabled)
|
||||
self.export_pdf_button.setDisabled(disabled)
|
||||
else:
|
||||
print("PDF/PNG export requires the PyMuPdf package!")
|
||||
self.export_png_button.setDisabled(True)
|
||||
self.export_pdf_button.setDisabled(True)
|
||||
|
||||
def load_plot_file(self, path: str | os.PathLike, force=False) -> None:
|
||||
try:
|
||||
with open(path, "rb") as fp:
|
||||
data = fp.read()
|
||||
if force:
|
||||
data = b"%1B" + data
|
||||
self.set_plot_data(data, path)
|
||||
except IOError as e:
|
||||
QtWidgets.QMessageBox.critical(self, "Loading Error", str(e))
|
||||
|
||||
def select_plot_file(self) -> None:
|
||||
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||
self,
|
||||
dir=str(self._current_file.parent),
|
||||
caption="Select HPGL/2 Plot File",
|
||||
filter="Plot Files (*.plt)",
|
||||
)
|
||||
if path:
|
||||
self.load_plot_file(path)
|
||||
|
||||
def set_plot_data(self, data: bytes, filename: str | os.PathLike) -> None:
|
||||
try:
|
||||
self._cad.plot(data)
|
||||
except api.Hpgl2Error as e:
|
||||
msg = f"Cannot plot HPGL/2 file '{filename}', {str(e)}"
|
||||
QtWidgets.QMessageBox.critical(self, "Plot Error", msg)
|
||||
return
|
||||
self._player = self._cad.player
|
||||
self._bbox = self._player.bbox()
|
||||
self._current_file = pathlib.Path(filename)
|
||||
self.update_colors(self._color_scheme)
|
||||
self.update_sidebar()
|
||||
self.setWindowTitle(f"{VIEWER_NAME} - " + str(filename))
|
||||
self.disable_export_buttons(False)
|
||||
|
||||
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
|
||||
self._view.fit_to_scene()
|
||||
|
||||
def get_scale_factor(self) -> float:
|
||||
try:
|
||||
return float(self.scaling_factor_line_edit.text())
|
||||
except ValueError:
|
||||
return 1.0
|
||||
|
||||
def get_dpi(self) -> int:
|
||||
try:
|
||||
return int(self.dpi_line_edit.text())
|
||||
except ValueError:
|
||||
return DEFAULT_DPI
|
||||
|
||||
def get_page_size(self) -> tuple[int, int]:
|
||||
factor = self.get_scale_factor()
|
||||
x = 0
|
||||
y = 0
|
||||
if self._bbox.has_data:
|
||||
size = self._bbox.size
|
||||
# 40 plot units = 1mm
|
||||
x = round(size.x / 40 * factor)
|
||||
y = round(size.y / 40 * factor)
|
||||
if self._page_rotation in (90, 270):
|
||||
x, y = y, x
|
||||
return x, y
|
||||
|
||||
def get_pixel_size(self) -> tuple[int, int]:
|
||||
dpi = self.get_dpi()
|
||||
x, y = self.get_page_size()
|
||||
return round(x / 25.4 * dpi), round(y / 25.4 * dpi)
|
||||
|
||||
def get_flip_x(self) -> bool:
|
||||
return self.flip_x_check_box.checkState() == QtCore.Qt.CheckState.Checked
|
||||
|
||||
def get_flip_y(self) -> bool:
|
||||
return self.flip_y_check_box.checkState() == QtCore.Qt.CheckState.Checked
|
||||
|
||||
def get_aci_export_mode(self) -> bool:
|
||||
return self.aci_export_mode.checkState() == QtCore.Qt.CheckState.Checked
|
||||
|
||||
def update_sidebar(self):
|
||||
x, y = self.get_page_size()
|
||||
self.page_size_label.setText(f"Page Size: {x}x{y}mm")
|
||||
px, py = self.get_pixel_size()
|
||||
self.png_size_label.setText(f"PNG Size: {px}x{py}px")
|
||||
self.clear_message()
|
||||
|
||||
def update_view(self):
|
||||
self._view.setTransform(self.view_transformation())
|
||||
self._view.fit_to_scene()
|
||||
self.update_sidebar()
|
||||
|
||||
def update_rotation(self, index: int):
|
||||
rotation = index * 90
|
||||
if rotation != self._page_rotation:
|
||||
self._page_rotation = rotation
|
||||
self.update_view()
|
||||
|
||||
def update_colors(self, index: int):
|
||||
self._color_scheme = index
|
||||
self._cad.replay(*replay_properties(index))
|
||||
self.update_view()
|
||||
|
||||
def view_transformation(self):
|
||||
if self._page_rotation == 0:
|
||||
m = Matrix44()
|
||||
else:
|
||||
m = Matrix44.z_rotate(math.radians(self._page_rotation))
|
||||
sx = -1 if self.get_flip_x() else 1
|
||||
# inverted y-axis
|
||||
sy = 1 if self.get_flip_y() else -1
|
||||
m @= Matrix44.scale(sx, sy, 1)
|
||||
return QtGui.QTransform(*m.get_2d_transformation())
|
||||
|
||||
def show_message(self, msg: str) -> None:
|
||||
self.message_label.setText(msg)
|
||||
|
||||
def clear_message(self) -> None:
|
||||
self.message_label.setText("")
|
||||
|
||||
def get_export_name(self, suffix: str) -> str:
|
||||
return str(self._current_file.with_suffix(suffix))
|
||||
|
||||
def export_svg(self) -> None:
|
||||
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self,
|
||||
dir=self.get_export_name(".svg"),
|
||||
caption="Save SVG File",
|
||||
filter="SVG Files (*.svg)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, "wt") as fp:
|
||||
fp.write(self.make_svg_string())
|
||||
self.show_message("SVG successfully exported")
|
||||
except IOError as e:
|
||||
QMessageBox.critical(self, "Export Error", str(e))
|
||||
|
||||
def get_export_matrix(self) -> Matrix44:
|
||||
scale = self.get_scale_factor()
|
||||
rotation = self._page_rotation
|
||||
sx = -scale if self.get_flip_x() else scale
|
||||
sy = -scale if self.get_flip_y() else scale
|
||||
if rotation in (90, 270):
|
||||
sx, sy = sy, sx
|
||||
m = Matrix44.scale(sx, sy, 1)
|
||||
if rotation:
|
||||
m @= Matrix44.z_rotate(math.radians(rotation))
|
||||
return m
|
||||
|
||||
def make_svg_string(self) -> str:
|
||||
"""Replays the HPGL/2 recordings on the SVGBackend of the drawing add-on."""
|
||||
player = self._player.copy()
|
||||
player.transform(self.get_export_matrix())
|
||||
size = player.bbox().size
|
||||
svg_backend = svg.SVGBackend()
|
||||
bg_color, override = replay_properties(self._color_scheme)
|
||||
xplayer.hpgl2_to_drawing(
|
||||
player, svg_backend, bg_color=bg_color, override=override
|
||||
)
|
||||
del player # free memory as soon as possible
|
||||
# 40 plot units == 1mm
|
||||
page = layout.Page(width=size.x / 40, height=size.y / 40)
|
||||
return svg_backend.get_string(page)
|
||||
|
||||
def export_pdf(self) -> None:
|
||||
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self,
|
||||
dir=self.get_export_name(".pdf"),
|
||||
caption="Save PDF File",
|
||||
filter="PDF Files (*.pdf)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, "wb") as fp:
|
||||
fp.write(self._pymupdf_export(fmt="pdf"))
|
||||
self.show_message("PDF successfully exported")
|
||||
except IOError as e:
|
||||
QMessageBox.critical(self, "Export Error", str(e))
|
||||
|
||||
def export_png(self) -> None:
|
||||
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self,
|
||||
dir=self.get_export_name(".png"),
|
||||
caption="Save PNG File",
|
||||
filter="PNG Files (*.png)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, "wb") as fp:
|
||||
fp.write(self._pymupdf_export(fmt="png"))
|
||||
self.show_message("PNG successfully exported")
|
||||
except IOError as e:
|
||||
QMessageBox.critical(self, "Export Error", str(e))
|
||||
|
||||
def _pymupdf_export(self, fmt: str) -> bytes:
|
||||
"""Replays the HPGL/2 recordings on the PyMuPdfBackend of the drawing add-on."""
|
||||
player = self._player.copy()
|
||||
player.transform(self.get_export_matrix())
|
||||
size = player.bbox().size
|
||||
pdf_backend = pymupdf.PyMuPdfBackend()
|
||||
bg_color, override = replay_properties(self._color_scheme)
|
||||
xplayer.hpgl2_to_drawing(
|
||||
player, pdf_backend, bg_color=bg_color, override=override
|
||||
)
|
||||
del player # free memory as soon as possible
|
||||
# 40 plot units == 1mm
|
||||
page = layout.Page(width=size.x / 40, height=size.y / 40)
|
||||
if fmt == "pdf":
|
||||
return pdf_backend.get_pdf_bytes(page)
|
||||
else:
|
||||
return pdf_backend.get_pixmap_bytes(page, fmt=fmt, dpi=self.get_dpi())
|
||||
|
||||
def export_dxf(self) -> None:
|
||||
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self,
|
||||
dir=self.get_export_name(".dxf"),
|
||||
caption="Save DXF File",
|
||||
filter="DXF Files (*.dxf)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
doc = self._get_dxf_document()
|
||||
try:
|
||||
doc.saveas(path)
|
||||
self.show_message("DXF successfully exported")
|
||||
except IOError as e:
|
||||
QMessageBox.critical(self, "Export Error", str(e))
|
||||
|
||||
def _get_dxf_document(self) -> Drawing:
|
||||
import ezdxf
|
||||
from ezdxf import zoom
|
||||
|
||||
color_mode = (
|
||||
dxf.ColorMode.ACI if self.get_aci_export_mode() else dxf.ColorMode.RGB
|
||||
)
|
||||
|
||||
doc = ezdxf.new()
|
||||
msp = doc.modelspace()
|
||||
player = self._player.copy()
|
||||
bbox = player.bbox()
|
||||
|
||||
m = self.get_export_matrix()
|
||||
corners = m.fast_2d_transform(bbox.rect_vertices())
|
||||
# move content to origin:
|
||||
tx, ty = BoundingBox2d(corners).extmin
|
||||
m @= Matrix44.translate(-tx, -ty, 0)
|
||||
player.transform(m)
|
||||
bbox = player.bbox()
|
||||
|
||||
dxf_backend = dxf.DXFBackend(msp, color_mode=color_mode)
|
||||
bg_color, override = replay_properties(self._color_scheme)
|
||||
if color_mode == dxf.ColorMode.RGB:
|
||||
doc.layers.add("BACKGROUND")
|
||||
bg = dxf.add_background(msp, bbox, colors.RGB.from_hex(bg_color))
|
||||
bg.dxf.layer = "BACKGROUND"
|
||||
# exports the HPGL/2 content in plot units (plu) as modelspace:
|
||||
# 1 plu = 0.025mm or 40 plu == 1mm
|
||||
xplayer.hpgl2_to_drawing(
|
||||
player, dxf_backend, bg_color=bg_color, override=override
|
||||
)
|
||||
del player
|
||||
if bbox.has_data: # non-empty page
|
||||
zoom.window(msp, bbox.extmin, bbox.extmax)
|
||||
dxf.update_extents(doc, bbox)
|
||||
# paperspace is set up in mm:
|
||||
dxf.setup_paperspace(doc, bbox)
|
||||
return doc
|
||||
|
||||
|
||||
def replay_properties(index: int) -> tuple[str, Any]:
|
||||
bg_color, override = "#ffffff", None # default
|
||||
if index == 1: # black on white
|
||||
bg_color, override = "#ffffff", xplayer.map_color("#000000")
|
||||
elif index == 2: # white on black
|
||||
bg_color, override = "#000000", xplayer.map_color("#ffffff")
|
||||
elif index == 3: # monochrome light
|
||||
bg_color, override = "#ffffff", xplayer.map_monochrome(dark_mode=False)
|
||||
elif index == 4: # monochrome dark
|
||||
bg_color, override = "#000000", xplayer.map_monochrome(dark_mode=True)
|
||||
elif index == 5: # blueprint high contrast
|
||||
bg_color, override = "#192c64", xplayer.map_color("#e9ebf3")
|
||||
elif index == 6: # blueprint low contrast
|
||||
bg_color, override = "#243f8f", xplayer.map_color("#bdc5dd")
|
||||
return bg_color, override
|
||||
@@ -0,0 +1,664 @@
|
||||
# Purpose: Import data from another DXF document
|
||||
# Copyright (c) 2013-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Iterable, cast, Union, Optional
|
||||
import logging
|
||||
from ezdxf.lldxf import const
|
||||
from ezdxf.render.arrows import ARROWS
|
||||
from ezdxf.entities import (
|
||||
DXFEntity,
|
||||
DXFGraphic,
|
||||
Hatch,
|
||||
Polyline,
|
||||
DimStyle,
|
||||
Dimension,
|
||||
Viewport,
|
||||
Linetype,
|
||||
Insert,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.layouts import BaseLayout, Layout
|
||||
|
||||
logger = logging.getLogger("ezdxf")
|
||||
|
||||
IMPORT_TABLES = ["linetypes", "layers", "styles", "dimstyles"]
|
||||
IMPORT_ENTITIES = {
|
||||
"LINE",
|
||||
"POINT",
|
||||
"CIRCLE",
|
||||
"ARC",
|
||||
"TEXT",
|
||||
"SOLID",
|
||||
"TRACE",
|
||||
"3DFACE",
|
||||
"SHAPE",
|
||||
"POLYLINE",
|
||||
"ATTRIB",
|
||||
"INSERT",
|
||||
"ELLIPSE",
|
||||
"MTEXT",
|
||||
"LWPOLYLINE",
|
||||
"SPLINE",
|
||||
"HATCH",
|
||||
"MESH",
|
||||
"XLINE",
|
||||
"RAY",
|
||||
"ATTDEF",
|
||||
"DIMENSION",
|
||||
"LEADER", # dimension style override not supported!
|
||||
"VIEWPORT",
|
||||
}
|
||||
|
||||
|
||||
class Importer:
|
||||
"""
|
||||
The :class:`Importer` class is central element for importing data from
|
||||
other DXF documents.
|
||||
|
||||
Args:
|
||||
source: source :class:`~ezdxf.drawing.Drawing`
|
||||
target: target :class:`~ezdxf.drawing.Drawing`
|
||||
|
||||
Attributes:
|
||||
source: source DXF document
|
||||
target: target DXF document
|
||||
used_layers: Set of used layer names as string, AutoCAD accepts layer
|
||||
names without a LAYER table entry.
|
||||
used_linetypes: Set of used linetype names as string, these linetypes
|
||||
require a TABLE entry or AutoCAD will crash.
|
||||
used_styles: Set of used text style names, these text styles require
|
||||
a TABLE entry or AutoCAD will crash.
|
||||
used_dimstyles: Set of used dimension style names, these dimension
|
||||
styles require a TABLE entry or AutoCAD will crash.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, source: Drawing, target: Drawing):
|
||||
self.source: Drawing = source
|
||||
self.target: Drawing = target
|
||||
|
||||
self.used_layers: set[str] = set()
|
||||
self.used_linetypes: set[str] = set()
|
||||
self.used_styles: set[str] = set()
|
||||
self.used_shape_files: set[str] = set() # style entry without a name!
|
||||
self.used_dimstyles: set[str] = set()
|
||||
self.used_arrows: set[str] = set()
|
||||
self.handle_mapping: dict[str, str] = dict() # old_handle: new_handle
|
||||
|
||||
# collects all imported INSERT entities, for later name resolving.
|
||||
self.imported_inserts: list[DXFEntity] = list() # imported inserts
|
||||
|
||||
# collects all imported block names and their assigned new name
|
||||
# imported_block[original_name] = new_name
|
||||
self.imported_blocks: dict[str, str] = dict()
|
||||
self._default_plotstyle_handle = target.plotstyles["Normal"].dxf.handle
|
||||
self._default_material_handle = target.materials["Global"].dxf.handle
|
||||
|
||||
def _add_used_resources(self, entity: DXFEntity) -> None:
|
||||
"""Register used resources."""
|
||||
self.used_layers.add(entity.get_dxf_attrib("layer", "0"))
|
||||
self.used_linetypes.add(entity.get_dxf_attrib("linetype", "BYLAYER"))
|
||||
if entity.is_supported_dxf_attrib("style"):
|
||||
self.used_styles.add(entity.get_dxf_attrib("style", "Standard"))
|
||||
if entity.is_supported_dxf_attrib("dimstyle"):
|
||||
self.used_dimstyles.add(entity.get_dxf_attrib("dimstyle", "Standard"))
|
||||
|
||||
def _add_dimstyle_resources(self, dimstyle: DimStyle) -> None:
|
||||
self.used_styles.add(dimstyle.get_dxf_attrib("dimtxsty", "Standard"))
|
||||
self.used_linetypes.add(dimstyle.get_dxf_attrib("dimltype", "BYLAYER"))
|
||||
self.used_linetypes.add(dimstyle.get_dxf_attrib("dimltex1", "BYLAYER"))
|
||||
self.used_linetypes.add(dimstyle.get_dxf_attrib("dimltex2", "BYLAYER"))
|
||||
self.used_arrows.add(dimstyle.get_dxf_attrib("dimblk", ""))
|
||||
self.used_arrows.add(dimstyle.get_dxf_attrib("dimblk1", ""))
|
||||
self.used_arrows.add(dimstyle.get_dxf_attrib("dimblk2", ""))
|
||||
self.used_arrows.add(dimstyle.get_dxf_attrib("dimldrblk", ""))
|
||||
|
||||
def _add_linetype_resources(self, linetype: Linetype) -> None:
|
||||
if not linetype.pattern_tags.is_complex_type():
|
||||
return
|
||||
style_handle = linetype.pattern_tags.get_style_handle()
|
||||
style = self.source.entitydb.get(style_handle)
|
||||
if style is None:
|
||||
return
|
||||
if style.dxf.name == "":
|
||||
# Shape file entries have no name!
|
||||
self.used_shape_files.add(style.dxf.font)
|
||||
else:
|
||||
self.used_styles.add(style.dxf.name)
|
||||
|
||||
def import_tables(
|
||||
self, table_names: Union[str, Iterable[str]] = "*", replace=False
|
||||
) -> None:
|
||||
"""Import DXF tables from the source document into the target document.
|
||||
|
||||
Args:
|
||||
table_names: iterable of tables names as strings, or a single table
|
||||
name as string or "*" for all supported tables
|
||||
replace: ``True`` to replace already existing table entries else
|
||||
ignore existing entries
|
||||
|
||||
Raises:
|
||||
TypeError: unsupported table type
|
||||
|
||||
"""
|
||||
if isinstance(table_names, str):
|
||||
if table_names == "*": # import all supported tables
|
||||
table_names = IMPORT_TABLES
|
||||
else: # import one specific table
|
||||
table_names = (table_names,)
|
||||
for table_name in table_names:
|
||||
self.import_table(table_name, entries="*", replace=replace)
|
||||
|
||||
def import_table(
|
||||
self, name: str, entries: Union[str, Iterable[str]] = "*", replace=False
|
||||
) -> None:
|
||||
"""
|
||||
Import specific table entries from the source document into the
|
||||
target document.
|
||||
|
||||
Args:
|
||||
name: valid table names are "layers", "linetypes" and "styles"
|
||||
entries: Iterable of table names as strings, or a single table name
|
||||
or "*" for all table entries
|
||||
replace: ``True`` to replace the already existing table entry else
|
||||
ignore existing entries
|
||||
|
||||
Raises:
|
||||
TypeError: unsupported table type
|
||||
|
||||
"""
|
||||
if name not in IMPORT_TABLES:
|
||||
raise TypeError(f'Table "{name}" import not supported.')
|
||||
source_table = getattr(self.source.tables, name)
|
||||
target_table = getattr(self.target.tables, name)
|
||||
|
||||
if isinstance(entries, str):
|
||||
if entries == "*": # import all table entries
|
||||
entries = (entry.dxf.name for entry in source_table)
|
||||
else: # import just one table entry
|
||||
entries = (entries,)
|
||||
for entry_name in entries:
|
||||
try:
|
||||
table_entry = source_table.get(entry_name)
|
||||
except const.DXFTableEntryError:
|
||||
logger.warning(
|
||||
f'Required table entry "{entry_name}" in table f{name} '
|
||||
f"not found."
|
||||
)
|
||||
continue
|
||||
entry_name = table_entry.dxf.name
|
||||
if entry_name in target_table:
|
||||
if replace:
|
||||
logger.debug(
|
||||
f'Replacing already existing entry "{entry_name}" '
|
||||
f"of {name} table."
|
||||
)
|
||||
target_table.remove(table_entry.dxf.name)
|
||||
else:
|
||||
logger.debug(
|
||||
f'Discarding already existing entry "{entry_name}" '
|
||||
f"of {name} table."
|
||||
)
|
||||
continue
|
||||
|
||||
if name == "layers":
|
||||
self.used_linetypes.add(
|
||||
table_entry.get_dxf_attrib("linetype", "Continuous")
|
||||
)
|
||||
elif name == "dimstyles":
|
||||
self._add_dimstyle_resources(table_entry)
|
||||
elif name == "linetypes":
|
||||
self._add_linetype_resources(table_entry)
|
||||
|
||||
# Duplicate table entry:
|
||||
new_table_entry = self._duplicate_table_entry(table_entry)
|
||||
target_table.add_entry(new_table_entry)
|
||||
|
||||
# Register resource handles for mapping:
|
||||
self.handle_mapping[table_entry.dxf.handle] = new_table_entry.dxf.handle
|
||||
|
||||
def import_shape_files(self, fonts: set[str]) -> None:
|
||||
"""Import shape file table entries from the source document into the
|
||||
target document.
|
||||
Shape file entries are stored in the styles table but without a name.
|
||||
|
||||
"""
|
||||
for font in fonts:
|
||||
table_entry = self.source.styles.find_shx(font)
|
||||
# copy is not necessary, just create a new entry:
|
||||
new_table_entry = self.target.styles.get_shx(font)
|
||||
if table_entry:
|
||||
# Register resource handles for mapping:
|
||||
self.handle_mapping[table_entry.dxf.handle] = new_table_entry.dxf.handle
|
||||
else:
|
||||
logger.warning(f'Required shape file entry "{font}" not found.')
|
||||
|
||||
def _set_table_entry_dxf_attribs(self, entity: DXFEntity) -> None:
|
||||
entity.doc = self.target
|
||||
if entity.dxf.hasattr("plotstyle_handle"):
|
||||
entity.dxf.plotstyle_handle = self._default_plotstyle_handle
|
||||
if entity.dxf.hasattr("material_handle"):
|
||||
entity.dxf.material_handle = self._default_material_handle
|
||||
|
||||
def _duplicate_table_entry(self, entry: DXFEntity) -> DXFEntity:
|
||||
# duplicate table entry
|
||||
new_entry = new_clean_entity(entry)
|
||||
self._set_table_entry_dxf_attribs(entry)
|
||||
|
||||
# create a new handle and add entity to target entity database
|
||||
self.target.entitydb.add(new_entry)
|
||||
return new_entry
|
||||
|
||||
def import_entity(
|
||||
self, entity: DXFEntity, target_layout: Optional[BaseLayout] = None
|
||||
) -> None:
|
||||
"""
|
||||
Imports a single DXF `entity` into `target_layout` or the modelspace
|
||||
of the target document, if `target_layout` is ``None``.
|
||||
|
||||
Args:
|
||||
entity: DXF entity to import
|
||||
target_layout: any layout (modelspace, paperspace or block) from
|
||||
the target document
|
||||
|
||||
Raises:
|
||||
DXFStructureError: `target_layout` is not a layout of target document
|
||||
|
||||
"""
|
||||
|
||||
def set_dxf_attribs(e):
|
||||
e.doc = self.target
|
||||
# remove invalid resources
|
||||
e.dxf.discard("plotstyle_handle")
|
||||
e.dxf.discard("material_handle")
|
||||
e.dxf.discard("visualstyle_handle")
|
||||
|
||||
if target_layout is None:
|
||||
target_layout = self.target.modelspace()
|
||||
elif target_layout.doc != self.target:
|
||||
raise const.DXFStructureError(
|
||||
"Target layout has to be a layout or block from the target " "document."
|
||||
)
|
||||
|
||||
dxftype = entity.dxftype()
|
||||
if dxftype not in IMPORT_ENTITIES:
|
||||
logger.debug(f"Import of {str(entity)} not supported")
|
||||
return
|
||||
self._add_used_resources(entity)
|
||||
|
||||
try:
|
||||
new_entity = cast(DXFGraphic, new_clean_entity(entity))
|
||||
except const.DXFTypeError:
|
||||
logger.debug(f"Copying for DXF type {dxftype} not supported.")
|
||||
return
|
||||
|
||||
set_dxf_attribs(new_entity)
|
||||
self.target.entitydb.add(new_entity)
|
||||
target_layout.add_entity(new_entity)
|
||||
|
||||
try: # additional processing
|
||||
getattr(self, "_import_" + dxftype.lower())(new_entity)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def _import_insert(self, insert: Insert):
|
||||
self.imported_inserts.append(insert)
|
||||
# remove all possible source document dependencies from sub entities
|
||||
for attrib in insert.attribs:
|
||||
remove_dependencies(attrib)
|
||||
|
||||
def _import_polyline(self, polyline: Polyline):
|
||||
# remove all possible source document dependencies from sub entities
|
||||
for vertex in polyline.vertices:
|
||||
remove_dependencies(vertex)
|
||||
|
||||
def _import_hatch(self, hatch: Hatch):
|
||||
hatch.dxf.discard("associative")
|
||||
|
||||
def _import_viewport(self, viewport: Viewport):
|
||||
viewport.dxf.discard("sun_handle")
|
||||
viewport.dxf.discard("clipping_boundary_handle")
|
||||
viewport.dxf.discard("ucs_handle")
|
||||
viewport.dxf.discard("ucs_base_handle")
|
||||
viewport.dxf.discard("background_handle")
|
||||
viewport.dxf.discard("shade_plot_handle")
|
||||
viewport.dxf.discard("visual_style_handle")
|
||||
viewport.dxf.discard("ref_vp_object_1")
|
||||
viewport.dxf.discard("ref_vp_object_2")
|
||||
viewport.dxf.discard("ref_vp_object_3")
|
||||
viewport.dxf.discard("ref_vp_object_4")
|
||||
|
||||
def _import_dimension(self, dimension: Dimension):
|
||||
if dimension.virtual_block_content:
|
||||
for entity in dimension.virtual_block_content:
|
||||
if isinstance(entity, Insert): # import arrow blocks
|
||||
self.import_block(entity.dxf.name, rename=False)
|
||||
self._add_used_resources(entity)
|
||||
else:
|
||||
logger.error("The required geometry block for DIMENSION is not defined.")
|
||||
|
||||
def import_entities(
|
||||
self,
|
||||
entities: Iterable[DXFEntity],
|
||||
target_layout: Optional[BaseLayout] = None,
|
||||
) -> None:
|
||||
"""Import all `entities` into `target_layout` or the modelspace of the
|
||||
target document, if `target_layout` is ``None``.
|
||||
|
||||
Args:
|
||||
entities: Iterable of DXF entities
|
||||
target_layout: any layout (modelspace, paperspace or block) from
|
||||
the target document
|
||||
|
||||
Raises:
|
||||
DXFStructureError: `target_layout` is not a layout of target document
|
||||
|
||||
"""
|
||||
for entity in entities:
|
||||
self.import_entity(entity, target_layout)
|
||||
|
||||
def import_modelspace(self, target_layout: Optional[BaseLayout] = None) -> None:
|
||||
"""Import all entities from source modelspace into `target_layout` or
|
||||
the modelspace of the target document, if `target_layout` is ``None``.
|
||||
|
||||
Args:
|
||||
target_layout: any layout (modelspace, paperspace or block) from
|
||||
the target document
|
||||
|
||||
Raises:
|
||||
DXFStructureError: `target_layout` is not a layout of target document
|
||||
|
||||
"""
|
||||
self.import_entities(self.source.modelspace(), target_layout=target_layout)
|
||||
|
||||
def recreate_source_layout(self, name: str) -> Layout:
|
||||
"""Recreate source paperspace layout `name` in the target document.
|
||||
The layout will be renamed if `name` already exist in the target
|
||||
document. Returns target modelspace for layout name "Model".
|
||||
|
||||
Args:
|
||||
name: layout name as string
|
||||
|
||||
Raises:
|
||||
KeyError: if source layout `name` not exist
|
||||
|
||||
"""
|
||||
|
||||
def get_target_name():
|
||||
tname = name
|
||||
base_name = name
|
||||
count = 1
|
||||
while tname in self.target.layouts:
|
||||
tname = base_name + str(count)
|
||||
count += 1
|
||||
|
||||
return tname
|
||||
|
||||
def clear(dxfattribs: dict) -> dict:
|
||||
def discard(name: str):
|
||||
try:
|
||||
del dxfattribs[name]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
discard("handle")
|
||||
discard("owner")
|
||||
discard("taborder")
|
||||
discard("shade_plot_handle")
|
||||
discard("block_record_handle")
|
||||
discard("viewport_handle")
|
||||
discard("ucs_handle")
|
||||
discard("base_ucs_handle")
|
||||
return dxfattribs
|
||||
|
||||
if name.lower() == "model":
|
||||
return self.target.modelspace()
|
||||
|
||||
source_layout = self.source.layouts.get(name) # raises KeyError
|
||||
target_name = get_target_name()
|
||||
dxfattribs = clear(source_layout.dxf_layout.dxfattribs())
|
||||
target_layout = self.target.layouts.new(target_name, dxfattribs=dxfattribs)
|
||||
return target_layout
|
||||
|
||||
def import_paperspace_layout(self, name: str) -> Layout:
|
||||
"""Import paperspace layout `name` into the target document.
|
||||
|
||||
Recreates the source paperspace layout in the target document, renames
|
||||
the target paperspace if a paperspace with same `name` already exist
|
||||
and imports all entities from the source paperspace into the target
|
||||
paperspace.
|
||||
|
||||
Args:
|
||||
name: source paper space name as string
|
||||
|
||||
Returns: new created target paperspace :class:`Layout`
|
||||
|
||||
Raises:
|
||||
KeyError: source paperspace does not exist
|
||||
DXFTypeError: invalid modelspace import
|
||||
|
||||
"""
|
||||
if name.lower() == "model":
|
||||
raise const.DXFTypeError(
|
||||
"Can not import modelspace, use method import_modelspace()."
|
||||
)
|
||||
source_layout = self.source.layouts.get(name)
|
||||
target_layout = self.recreate_source_layout(name)
|
||||
self.import_entities(source_layout, target_layout)
|
||||
return target_layout
|
||||
|
||||
def import_paperspace_layouts(self) -> None:
|
||||
"""Import all paperspace layouts and their content into the target
|
||||
document.
|
||||
Target layouts will be renamed if a layout with the same name already
|
||||
exist. Layouts will be imported in original tab order.
|
||||
|
||||
"""
|
||||
for name in self.source.layouts.names_in_taborder():
|
||||
if name.lower() != "model": # do not import modelspace
|
||||
self.import_paperspace_layout(name)
|
||||
|
||||
def import_blocks(self, block_names: Iterable[str], rename=False) -> None:
|
||||
"""Import all BLOCK definitions from source document.
|
||||
|
||||
If a BLOCK already exist the BLOCK will be renamed if argument
|
||||
`rename` is ``True``, otherwise the existing BLOCK in the target
|
||||
document will be used instead of the BLOCK from the source document.
|
||||
Required name resolving for imported BLOCK references (INSERT), will be
|
||||
done in the :meth:`Importer.finalize` method.
|
||||
|
||||
Args:
|
||||
block_names: names of BLOCK definitions to import
|
||||
rename: rename BLOCK if a BLOCK with the same name already exist in
|
||||
target document
|
||||
|
||||
Raises:
|
||||
ValueError: BLOCK in source document not found (defined)
|
||||
|
||||
"""
|
||||
for block_name in block_names:
|
||||
self.import_block(block_name, rename=rename)
|
||||
|
||||
def import_block(self, block_name: str, rename=True) -> str:
|
||||
"""Import one BLOCK definition from source document.
|
||||
|
||||
If the BLOCK already exist the BLOCK will be renamed if argument
|
||||
`rename` is ``True``, otherwise the existing BLOCK in the target
|
||||
document will be used instead of the BLOCK in the source document.
|
||||
Required name resolving for imported block references (INSERT), will be
|
||||
done in the :meth:`Importer.finalize` method.
|
||||
|
||||
To replace an existing BLOCK in the target document, just delete it
|
||||
before importing data:
|
||||
:code:`target.blocks.delete_block(block_name, safe=False)`
|
||||
|
||||
Args:
|
||||
block_name: name of BLOCK to import
|
||||
rename: rename BLOCK if a BLOCK with the same name already exist in
|
||||
target document
|
||||
|
||||
Returns: (renamed) BLOCK name
|
||||
|
||||
Raises:
|
||||
ValueError: BLOCK in source document not found (defined)
|
||||
|
||||
"""
|
||||
|
||||
def get_new_block_name() -> str:
|
||||
num = 0
|
||||
name = block_name
|
||||
while name in target_blocks:
|
||||
name = block_name + str(num)
|
||||
num += 1
|
||||
return name
|
||||
|
||||
try: # already imported block?
|
||||
return self.imported_blocks[block_name]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
source_block = self.source.blocks[block_name]
|
||||
except const.DXFKeyError:
|
||||
raise ValueError(f'Source block "{block_name}" not found.')
|
||||
|
||||
target_blocks = self.target.blocks
|
||||
if (block_name in target_blocks) and (rename is False):
|
||||
self.imported_blocks[block_name] = block_name
|
||||
return block_name
|
||||
|
||||
new_block_name = get_new_block_name()
|
||||
block = source_block.block
|
||||
assert block is not None
|
||||
target_block = target_blocks.new(
|
||||
new_block_name,
|
||||
base_point=block.dxf.base_point,
|
||||
dxfattribs={
|
||||
"description": block.dxf.description,
|
||||
"flags": block.dxf.flags,
|
||||
"xref_path": block.dxf.xref_path,
|
||||
},
|
||||
)
|
||||
self.import_entities(source_block, target_layout=target_block)
|
||||
self.imported_blocks[block_name] = new_block_name
|
||||
return new_block_name
|
||||
|
||||
def _create_missing_arrows(self):
|
||||
"""Create or import required arrow blocks, used by the LEADER or the
|
||||
DIMSTYLE entity, which are not imported automatically because they are
|
||||
not used in an anonymous DIMENSION geometry BLOCK.
|
||||
|
||||
"""
|
||||
self.used_arrows.discard(
|
||||
""
|
||||
) # standard default arrow '' needs no block definition
|
||||
for arrow_name in self.used_arrows:
|
||||
if ARROWS.is_acad_arrow(arrow_name):
|
||||
self.target.acquire_arrow(arrow_name)
|
||||
else:
|
||||
self.import_block(arrow_name, rename=False)
|
||||
|
||||
def _resolve_inserts(self) -> None:
|
||||
"""Resolve BLOCK names of imported BLOCK reference entities (INSERT).
|
||||
|
||||
This is required for the case the name of the imported BLOCK collides
|
||||
with an already existing BLOCK in the target document and the conflict
|
||||
resolving method is "rename".
|
||||
|
||||
"""
|
||||
while len(self.imported_inserts):
|
||||
inserts = list(self.imported_inserts)
|
||||
# clear imported inserts, block import may append additional inserts
|
||||
self.imported_inserts = []
|
||||
for insert in inserts:
|
||||
block_name = self.import_block(insert.dxf.name)
|
||||
insert.dxf.name = block_name
|
||||
|
||||
def _import_required_table_entries(self) -> None:
|
||||
"""Import required table entries collected while importing entities
|
||||
into the target document.
|
||||
|
||||
"""
|
||||
# 1. dimstyles import adds additional required linetype and style
|
||||
# resources and required arrows
|
||||
if len(self.used_dimstyles):
|
||||
self.import_table("dimstyles", self.used_dimstyles)
|
||||
|
||||
# 2. layers import adds additional required linetype resources
|
||||
if len(self.used_layers):
|
||||
self.import_table("layers", self.used_layers)
|
||||
|
||||
# 3. complex linetypes adds additional required style resources
|
||||
if len(self.used_linetypes):
|
||||
self.import_table("linetypes", self.used_linetypes)
|
||||
|
||||
# 4. Text styles do not add additional required resources
|
||||
if len(self.used_styles):
|
||||
self.import_table("styles", self.used_styles)
|
||||
|
||||
# 5. Shape files are text style entries without a name
|
||||
if len(self.used_shape_files):
|
||||
self.import_shape_files(self.used_shape_files)
|
||||
|
||||
# 6. Update text style handles of imported complex linetypes:
|
||||
self.update_complex_linetypes()
|
||||
|
||||
def _add_required_complex_linetype_resources(self):
|
||||
for ltype_name in self.used_linetypes:
|
||||
try:
|
||||
ltype = self.source.linetypes.get(ltype_name)
|
||||
except const.DXFTableEntryError:
|
||||
continue
|
||||
self._add_linetype_resources(ltype)
|
||||
|
||||
def update_complex_linetypes(self):
|
||||
std_handle = self.target.styles.get("STANDARD").dxf.handle
|
||||
for linetype in self.target.linetypes:
|
||||
if linetype.pattern_tags.is_complex_type():
|
||||
old_handle = linetype.pattern_tags.get_style_handle()
|
||||
new_handle = self.handle_mapping.get(old_handle, std_handle)
|
||||
linetype.pattern_tags.set_style_handle(new_handle)
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Finalize the import by importing required table entries and BLOCK
|
||||
definitions, without finalization the target document is maybe invalid
|
||||
for AutoCAD. Call the :meth:`~Importer.finalize()` method as last step
|
||||
of the import process.
|
||||
|
||||
"""
|
||||
self._resolve_inserts()
|
||||
self._add_required_complex_linetype_resources()
|
||||
self._import_required_table_entries()
|
||||
self._create_missing_arrows()
|
||||
|
||||
|
||||
def new_clean_entity(entity: DXFEntity, keep_xdata: bool = False) -> DXFEntity:
|
||||
"""Copy entity and remove all external dependencies.
|
||||
|
||||
Args:
|
||||
entity: DXF entity
|
||||
keep_xdata: keep xdata flag
|
||||
|
||||
"""
|
||||
new_entity = entity.copy()
|
||||
new_entity.doc = None
|
||||
return remove_dependencies(new_entity, keep_xdata=keep_xdata)
|
||||
|
||||
|
||||
def remove_dependencies(entity: DXFEntity, keep_xdata: bool = False) -> DXFEntity:
|
||||
"""Remove all external dependencies.
|
||||
|
||||
Args:
|
||||
entity: DXF entity
|
||||
keep_xdata: keep xdata flag
|
||||
|
||||
"""
|
||||
entity.appdata = None
|
||||
entity.reactors = None
|
||||
entity.extension_dict = None
|
||||
if not keep_xdata:
|
||||
entity.xdata = None
|
||||
return entity
|
||||
@@ -0,0 +1,480 @@
|
||||
# Copyright (c) 2020-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Iterable,
|
||||
Iterator,
|
||||
cast,
|
||||
BinaryIO,
|
||||
Optional,
|
||||
Union,
|
||||
Any,
|
||||
)
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from ezdxf.lldxf.const import DXFStructureError
|
||||
from ezdxf.lldxf.extendedtags import ExtendedTags, DXFTag
|
||||
from ezdxf.lldxf.tagwriter import TagWriter
|
||||
from ezdxf.lldxf.tagger import tag_compiler, ascii_tags_loader
|
||||
from ezdxf.filemanagement import dxf_file_info
|
||||
from ezdxf.lldxf import fileindex
|
||||
|
||||
from ezdxf.entities import DXFGraphic, DXFEntity, Polyline, Insert
|
||||
from ezdxf.entities import factory
|
||||
from ezdxf.entities.subentity import entity_linker
|
||||
from ezdxf.tools.codepage import toencoding
|
||||
|
||||
__all__ = ["opendxf", "single_pass_modelspace", "modelspace"]
|
||||
|
||||
SUPPORTED_TYPES = {
|
||||
"ARC",
|
||||
"LINE",
|
||||
"CIRCLE",
|
||||
"ELLIPSE",
|
||||
"POINT",
|
||||
"LWPOLYLINE",
|
||||
"SPLINE",
|
||||
"3DFACE",
|
||||
"SOLID",
|
||||
"TRACE",
|
||||
"SHAPE",
|
||||
"POLYLINE",
|
||||
"VERTEX",
|
||||
"SEQEND",
|
||||
"MESH",
|
||||
"TEXT",
|
||||
"MTEXT",
|
||||
"HATCH",
|
||||
"INSERT",
|
||||
"ATTRIB",
|
||||
"ATTDEF",
|
||||
"RAY",
|
||||
"XLINE",
|
||||
"DIMENSION",
|
||||
"LEADER",
|
||||
"IMAGE",
|
||||
"WIPEOUT",
|
||||
"HELIX",
|
||||
"MLINE",
|
||||
"MLEADER",
|
||||
}
|
||||
|
||||
Filename = Union[Path, str]
|
||||
|
||||
|
||||
class IterDXF:
|
||||
"""Iterator for DXF entities stored in the modelspace.
|
||||
|
||||
Args:
|
||||
name: filename, has to be a seekable file.
|
||||
errors: specify decoding error handler
|
||||
|
||||
- "surrogateescape" to preserve possible binary data (default)
|
||||
- "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
|
||||
- "strict" to raise an :class:`UnicodeDecodeError`exception for invalid data
|
||||
|
||||
Raises:
|
||||
DXFStructureError: invalid or incomplete DXF file
|
||||
UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, name: Filename, errors: str = "surrogateescape"):
|
||||
self.structure, self.sections = self._load_index(str(name))
|
||||
self.errors = errors
|
||||
self.file: BinaryIO = open(name, mode="rb")
|
||||
if "ENTITIES" not in self.sections:
|
||||
raise DXFStructureError("ENTITIES section not found.")
|
||||
if self.structure.version > "AC1009" and "OBJECTS" not in self.sections:
|
||||
raise DXFStructureError("OBJECTS section not found.")
|
||||
|
||||
def _load_index(
|
||||
self, name: str
|
||||
) -> tuple[fileindex.FileStructure, dict[str, int]]:
|
||||
structure = fileindex.load(name)
|
||||
sections: dict[str, int] = dict()
|
||||
new_index = []
|
||||
for e in structure.index:
|
||||
if e.code == 0:
|
||||
new_index.append(e)
|
||||
elif e.code == 2:
|
||||
sections[e.value] = len(new_index) - 1
|
||||
# remove all other tags like handles (code == 5)
|
||||
structure.index = new_index
|
||||
return structure, sections
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return self.structure.encoding
|
||||
|
||||
@property
|
||||
def dxfversion(self):
|
||||
return self.structure.version
|
||||
|
||||
def export(self, name: Filename) -> IterDXFWriter:
|
||||
"""Returns a companion object to export parts from the source DXF file
|
||||
into another DXF file, the new file will have the same HEADER, CLASSES,
|
||||
TABLES, BLOCKS and OBJECTS sections, which guarantees all necessary
|
||||
dependencies are present in the new file.
|
||||
|
||||
Args:
|
||||
name: filename, no special requirements
|
||||
|
||||
"""
|
||||
doc = IterDXFWriter(name, self)
|
||||
# Copy everything from start of source DXF until the first entity
|
||||
# of the ENTITIES section to the new DXF.
|
||||
location = self.structure.index[self.sections["ENTITIES"] + 1].location
|
||||
self.file.seek(0)
|
||||
data = self.file.read(location)
|
||||
doc.write_data(data)
|
||||
return doc
|
||||
|
||||
def copy_objects_section(self, f: BinaryIO) -> None:
|
||||
start_index = self.sections["OBJECTS"]
|
||||
try:
|
||||
end_index = self.structure.get(0, "ENDSEC", start_index)
|
||||
except ValueError:
|
||||
raise DXFStructureError(f"ENDSEC of OBJECTS section not found.")
|
||||
|
||||
start_location = self.structure.index[start_index].location
|
||||
end_location = self.structure.index[end_index + 1].location
|
||||
count = end_location - start_location
|
||||
self.file.seek(start_location)
|
||||
data = self.file.read(count)
|
||||
f.write(data)
|
||||
|
||||
def modelspace(
|
||||
self, types: Optional[Iterable[str]] = None
|
||||
) -> Iterable[DXFGraphic]:
|
||||
"""Returns an iterator for all supported DXF entities in the
|
||||
modelspace. These entities are regular :class:`~ezdxf.entities.DXFGraphic`
|
||||
objects but without a valid document assigned. It is **not**
|
||||
possible to add these entities to other `ezdxf` documents.
|
||||
|
||||
It is only possible to recreate the objects by factory functions base
|
||||
on attributes of the source entity.
|
||||
For MESH, POLYMESH and POLYFACE it is possible to use the
|
||||
:class:`~ezdxf.render.MeshTransformer` class to render (recreate) this
|
||||
objects as new entities in another document.
|
||||
|
||||
Args:
|
||||
types: DXF types like ``['LINE', '3DFACE']`` which should be
|
||||
returned, ``None`` returns all supported types.
|
||||
|
||||
"""
|
||||
linked_entity = entity_linker()
|
||||
queued = None
|
||||
requested_types = _requested_types(types)
|
||||
for entity in self.load_entities(
|
||||
self.sections["ENTITIES"] + 1, requested_types
|
||||
):
|
||||
if not linked_entity(entity) and entity.dxf.paperspace == 0:
|
||||
# queue one entity for collecting linked entities:
|
||||
# VERTEX, ATTRIB
|
||||
if queued:
|
||||
yield queued
|
||||
queued = entity
|
||||
if queued:
|
||||
yield queued
|
||||
|
||||
def load_entities(
|
||||
self, start: int, requested_types: set[str]
|
||||
) -> Iterable[DXFGraphic]:
|
||||
def to_str(data: bytes) -> str:
|
||||
return data.decode(self.encoding, errors=self.errors).replace(
|
||||
"\r\n", "\n"
|
||||
)
|
||||
|
||||
index = start
|
||||
entry = self.structure.index[index]
|
||||
self.file.seek(entry.location)
|
||||
while entry.value != "ENDSEC":
|
||||
index += 1
|
||||
next_entry = self.structure.index[index]
|
||||
size = next_entry.location - entry.location
|
||||
data = self.file.read(size)
|
||||
if entry.value in requested_types:
|
||||
xtags = ExtendedTags.from_text(to_str(data))
|
||||
yield factory.load(xtags) # type: ignore
|
||||
entry = next_entry
|
||||
|
||||
def close(self):
|
||||
"""Safe closing source DXF file."""
|
||||
self.file.close()
|
||||
|
||||
|
||||
class IterDXFWriter:
|
||||
def __init__(self, name: Filename, loader: IterDXF):
|
||||
self.name = str(name)
|
||||
self.file: BinaryIO = open(name, mode="wb")
|
||||
self.text = StringIO()
|
||||
self.entity_writer = TagWriter(self.text, loader.dxfversion)
|
||||
self.loader = loader
|
||||
|
||||
def write_data(self, data: bytes):
|
||||
self.file.write(data)
|
||||
|
||||
def write(self, entity: DXFGraphic):
|
||||
"""Write a DXF entity from the source DXF file to the export file.
|
||||
|
||||
Don't write entities from different documents than the source DXF file,
|
||||
dependencies and resources will not match, maybe it will work once, but
|
||||
not in a reliable way for different DXF documents.
|
||||
|
||||
"""
|
||||
# Not necessary to remove this dependencies by copying
|
||||
# them into the same document frame
|
||||
# ---------------------------------
|
||||
# remove all possible dependencies
|
||||
# entity.xdata = None
|
||||
# entity.appdata = None
|
||||
# entity.extension_dict = None
|
||||
# entity.reactors = None
|
||||
# reset text stream
|
||||
self.text.seek(0)
|
||||
self.text.truncate()
|
||||
|
||||
if entity.dxf.handle is None: # DXF R12 without handles
|
||||
self.entity_writer.write_handles = False
|
||||
|
||||
entity.export_dxf(self.entity_writer)
|
||||
if entity.dxftype() == "POLYLINE":
|
||||
polyline = cast(Polyline, entity)
|
||||
for vertex in polyline.vertices:
|
||||
vertex.export_dxf(self.entity_writer)
|
||||
polyline.seqend.export_dxf(self.entity_writer) # type: ignore
|
||||
elif entity.dxftype() == "INSERT":
|
||||
insert = cast(Insert, entity)
|
||||
if insert.attribs_follow:
|
||||
for attrib in insert.attribs:
|
||||
attrib.export_dxf(self.entity_writer)
|
||||
insert.seqend.export_dxf(self.entity_writer) # type: ignore
|
||||
data = self.text.getvalue().encode(self.loader.encoding)
|
||||
self.file.write(data)
|
||||
|
||||
def close(self):
|
||||
"""Safe closing of exported DXF file. Copying of OBJECTS section
|
||||
happens only at closing the file, without closing the new DXF file is
|
||||
invalid.
|
||||
"""
|
||||
self.file.write(b" 0\r\nENDSEC\r\n") # for ENTITIES section
|
||||
if self.loader.dxfversion > "AC1009":
|
||||
self.loader.copy_objects_section(self.file)
|
||||
self.file.write(b" 0\r\nEOF\r\n")
|
||||
self.file.close()
|
||||
|
||||
|
||||
def opendxf(filename: Filename, errors: str = "surrogateescape") -> IterDXF:
|
||||
"""Open DXF file for iterating, be sure to open valid DXF files, no DXF
|
||||
structure checks will be applied.
|
||||
|
||||
Use this function to split up big DXF files as shown in the example above.
|
||||
|
||||
Args:
|
||||
filename: DXF filename of a seekable DXF file.
|
||||
errors: specify decoding error handler
|
||||
|
||||
- "surrogateescape" to preserve possible binary data (default)
|
||||
- "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
|
||||
- "strict" to raise an :class:`UnicodeDecodeError` exception for invalid data
|
||||
|
||||
Raises:
|
||||
DXFStructureError: invalid or incomplete DXF file
|
||||
UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs
|
||||
|
||||
"""
|
||||
return IterDXF(filename, errors=errors)
|
||||
|
||||
|
||||
def modelspace(
|
||||
filename: Filename,
|
||||
types: Optional[Iterable[str]] = None,
|
||||
errors: str = "surrogateescape",
|
||||
) -> Iterable[DXFGraphic]:
|
||||
"""Iterate over all modelspace entities as :class:`DXFGraphic` objects of
|
||||
a seekable file.
|
||||
|
||||
Use this function to iterate "quick" over modelspace entities of a DXF file,
|
||||
filtering DXF types may speed up things if many entity types will be skipped.
|
||||
|
||||
Args:
|
||||
filename: filename of a seekable DXF file
|
||||
types: DXF types like ``['LINE', '3DFACE']`` which should be returned,
|
||||
``None`` returns all supported types.
|
||||
errors: specify decoding error handler
|
||||
|
||||
- "surrogateescape" to preserve possible binary data (default)
|
||||
- "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
|
||||
- "strict" to raise an :class:`UnicodeDecodeError` exception for invalid data
|
||||
|
||||
Raises:
|
||||
DXFStructureError: invalid or incomplete DXF file
|
||||
UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs
|
||||
|
||||
"""
|
||||
info = dxf_file_info(str(filename))
|
||||
prev_code: int = -1
|
||||
prev_value: Any = ""
|
||||
entities = False
|
||||
requested_types = _requested_types(types)
|
||||
|
||||
with open(filename, mode="rt", encoding=info.encoding, errors=errors) as fp:
|
||||
tagger = ascii_tags_loader(fp)
|
||||
queued: Optional[DXFEntity] = None
|
||||
tags: list[DXFTag] = []
|
||||
linked_entity = entity_linker()
|
||||
|
||||
for tag in tag_compiler(tagger):
|
||||
code = tag.code
|
||||
value = tag.value
|
||||
if entities:
|
||||
if code == 0:
|
||||
if len(tags) and tags[0].value in requested_types:
|
||||
entity = factory.load(ExtendedTags(tags))
|
||||
if (
|
||||
not linked_entity(entity)
|
||||
and entity.dxf.paperspace == 0
|
||||
):
|
||||
# queue one entity for collecting linked entities:
|
||||
# VERTEX, ATTRIB
|
||||
if queued:
|
||||
yield queued # type: ignore
|
||||
queued = entity
|
||||
tags = [tag]
|
||||
else:
|
||||
tags.append(tag)
|
||||
if code == 0 and value == "ENDSEC":
|
||||
if queued:
|
||||
yield queued # type: ignore
|
||||
return
|
||||
continue # if entities - nothing else matters
|
||||
elif code == 2 and prev_code == 0 and prev_value == "SECTION":
|
||||
entities = value == "ENTITIES"
|
||||
|
||||
prev_code = code
|
||||
prev_value = value
|
||||
|
||||
|
||||
def single_pass_modelspace(
|
||||
stream: BinaryIO,
|
||||
types: Optional[Iterable[str]] = None,
|
||||
errors: str = "surrogateescape",
|
||||
) -> Iterable[DXFGraphic]:
|
||||
"""Iterate over all modelspace entities as :class:`DXFGraphic` objects in
|
||||
a single pass.
|
||||
|
||||
Use this function to 'quick' iterate over modelspace entities of a **not**
|
||||
seekable binary DXF stream, filtering DXF types may speed up things if many
|
||||
entity types will be skipped.
|
||||
|
||||
Args:
|
||||
stream: (not seekable) binary DXF stream
|
||||
types: DXF types like ``['LINE', '3DFACE']`` which should be returned,
|
||||
``None`` returns all supported types.
|
||||
errors: specify decoding error handler
|
||||
|
||||
- "surrogateescape" to preserve possible binary data (default)
|
||||
- "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
|
||||
- "strict" to raise an :class:`UnicodeDecodeError` exception for invalid data
|
||||
|
||||
Raises:
|
||||
DXFStructureError: Invalid or incomplete DXF file
|
||||
UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs
|
||||
|
||||
"""
|
||||
fetch_header_var: Optional[str] = None
|
||||
encoding = "cp1252"
|
||||
version = "AC1009"
|
||||
prev_code: int = -1
|
||||
prev_value: str = ""
|
||||
entities = False
|
||||
requested_types = _requested_types(types)
|
||||
|
||||
for code, value in binary_tagger(stream):
|
||||
if code == 0 and value == b"ENDSEC":
|
||||
break
|
||||
elif code == 2 and prev_code == 0 and value != b"HEADER":
|
||||
# (0, SECTION), (2, name)
|
||||
# First section is not the HEADER section
|
||||
entities = value == b"ENTITIES"
|
||||
break
|
||||
elif code == 9 and value == b"$DWGCODEPAGE":
|
||||
fetch_header_var = "ENCODING"
|
||||
elif code == 9 and value == b"$ACADVER":
|
||||
fetch_header_var = "VERSION"
|
||||
elif fetch_header_var == "ENCODING":
|
||||
encoding = toencoding(value.decode())
|
||||
fetch_header_var = None
|
||||
elif fetch_header_var == "VERSION":
|
||||
version = value.decode()
|
||||
fetch_header_var = None
|
||||
prev_code = code
|
||||
|
||||
if version >= "AC1021":
|
||||
encoding = "utf-8"
|
||||
|
||||
queued: Optional[DXFGraphic] = None
|
||||
tags: list[DXFTag] = []
|
||||
linked_entity = entity_linker()
|
||||
|
||||
for tag in tag_compiler(binary_tagger(stream, encoding, errors)):
|
||||
code = tag.code
|
||||
value = tag.value
|
||||
if entities:
|
||||
if code == 0 and value == "ENDSEC":
|
||||
if queued:
|
||||
yield queued
|
||||
return
|
||||
if code == 0:
|
||||
if len(tags) and tags[0].value in requested_types:
|
||||
entity = cast(DXFGraphic, factory.load(ExtendedTags(tags)))
|
||||
if not linked_entity(entity) and entity.dxf.paperspace == 0:
|
||||
# queue one entity for collecting linked entities:
|
||||
# VERTEX, ATTRIB
|
||||
if queued:
|
||||
yield queued
|
||||
queued = entity
|
||||
tags = [tag]
|
||||
else:
|
||||
tags.append(tag)
|
||||
continue # if entities - nothing else matters
|
||||
elif code == 2 and prev_code == 0 and prev_value == "SECTION":
|
||||
entities = value == "ENTITIES"
|
||||
|
||||
prev_code = code
|
||||
prev_value = value
|
||||
|
||||
|
||||
def binary_tagger(
|
||||
file: BinaryIO,
|
||||
encoding: Optional[str] = None,
|
||||
errors: str = "surrogateescape",
|
||||
) -> Iterator[DXFTag]:
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
code = int(file.readline())
|
||||
except ValueError:
|
||||
raise DXFStructureError(f"Invalid group code")
|
||||
value = file.readline().rstrip(b"\r\n")
|
||||
yield DXFTag(
|
||||
code,
|
||||
value.decode(encoding, errors=errors) if encoding else value,
|
||||
)
|
||||
except IOError:
|
||||
return
|
||||
|
||||
|
||||
def _requested_types(types: Optional[Iterable[str]]) -> set[str]:
|
||||
if types:
|
||||
requested = SUPPORTED_TYPES.intersection(set(types))
|
||||
if "POLYLINE" in requested:
|
||||
requested.add("SEQEND")
|
||||
requested.add("VERTEX")
|
||||
if "INSERT" in requested:
|
||||
requested.add("SEQEND")
|
||||
requested.add("ATTRIB")
|
||||
else:
|
||||
requested = SUPPORTED_TYPES
|
||||
return requested
|
||||
@@ -0,0 +1,266 @@
|
||||
# Purpose: menger sponge addon for ezdxf
|
||||
# Copyright (c) 2016-2022 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Iterator, Sequence, Optional
|
||||
from ezdxf.math import Vec3, UVec, Matrix44, UCS
|
||||
from ezdxf.render.mesh import MeshVertexMerger, MeshTransformer, MeshBuilder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.eztypes import GenericLayoutType
|
||||
|
||||
# fmt: off
|
||||
all_cubes_size_3_template = [
|
||||
(0, 0, 0), (1, 0, 0), (2, 0, 0), (0, 1, 0), (1, 1, 0), (2, 1, 0), (0, 2, 0), (1, 2, 0), (2, 2, 0),
|
||||
(0, 0, 1), (1, 0, 1), (2, 0, 1), (0, 1, 1), (1, 1, 1), (2, 1, 1), (0, 2, 1), (1, 2, 1), (2, 2, 1),
|
||||
(0, 0, 2), (1, 0, 2), (2, 0, 2), (0, 1, 2), (1, 1, 2), (2, 1, 2), (0, 2, 2), (1, 2, 2), (2, 2, 2),
|
||||
]
|
||||
|
||||
original_menger_cubes = [
|
||||
(0, 0, 0), (1, 0, 0), (2, 0, 0), (0, 1, 0), (2, 1, 0), (0, 2, 0), (1, 2, 0), (2, 2, 0),
|
||||
(0, 0, 1), (2, 0, 1), (0, 2, 1), (2, 2, 1),
|
||||
(0, 0, 2), (1, 0, 2), (2, 0, 2), (0, 1, 2), (2, 1, 2), (0, 2, 2), (1, 2, 2), (2, 2, 2),
|
||||
]
|
||||
|
||||
menger_v1 = [
|
||||
(0, 0, 0), (2, 0, 0), (1, 1, 0), (0, 2, 0), (2, 2, 0),
|
||||
(1, 0, 1), (0, 1, 1), (2, 1, 1), (1, 2, 1),
|
||||
(0, 0, 2), (2, 0, 2), (1, 1, 2), (0, 2, 2), (2, 2, 2),
|
||||
]
|
||||
|
||||
menger_v2 = [
|
||||
(1, 0, 0), (0, 1, 0), (2, 1, 0), (1, 2, 0),
|
||||
(0, 0, 1), (2, 0, 1), (1, 1, 1), (0, 2, 1), (2, 2, 1),
|
||||
(1, 0, 2), (0, 1, 2), (2, 1, 2), (1, 2, 2),
|
||||
]
|
||||
|
||||
jerusalem_cube = [
|
||||
(0, 0, 0), (1, 0, 0), (2, 0, 0), (3, 0, 0), (4, 0, 0), (0, 1, 0), (1, 1, 0), (3, 1, 0), (4, 1, 0), (0, 2, 0),
|
||||
(4, 2, 0), (0, 3, 0), (1, 3, 0), (3, 3, 0), (4, 3, 0), (0, 4, 0), (1, 4, 0), (2, 4, 0), (3, 4, 0), (4, 4, 0),
|
||||
(0, 0, 1), (1, 0, 1), (3, 0, 1), (4, 0, 1), (0, 1, 1), (1, 1, 1), (3, 1, 1), (4, 1, 1), (0, 3, 1), (1, 3, 1),
|
||||
(3, 3, 1), (4, 3, 1), (0, 4, 1), (1, 4, 1), (3, 4, 1), (4, 4, 1), (0, 0, 2), (4, 0, 2), (0, 4, 2), (4, 4, 2),
|
||||
(0, 0, 3), (1, 0, 3), (3, 0, 3), (4, 0, 3), (0, 1, 3), (1, 1, 3), (3, 1, 3), (4, 1, 3), (0, 3, 3), (1, 3, 3),
|
||||
(3, 3, 3), (4, 3, 3), (0, 4, 3), (1, 4, 3), (3, 4, 3), (4, 4, 3), (0, 0, 4), (1, 0, 4), (2, 0, 4), (3, 0, 4),
|
||||
(4, 0, 4), (0, 1, 4), (1, 1, 4), (3, 1, 4), (4, 1, 4), (0, 2, 4), (4, 2, 4), (0, 3, 4), (1, 3, 4), (3, 3, 4),
|
||||
(4, 3, 4), (0, 4, 4), (1, 4, 4), (2, 4, 4), (3, 4, 4), (4, 4, 4),
|
||||
]
|
||||
|
||||
building_schemas = [
|
||||
original_menger_cubes,
|
||||
menger_v1,
|
||||
menger_v2,
|
||||
jerusalem_cube,
|
||||
]
|
||||
|
||||
# subdivide level in order of building_schemas
|
||||
cube_sizes = [3., 3., 3., 5.]
|
||||
|
||||
# 8 corner vertices
|
||||
_cube_vertices = [
|
||||
(0, 0, 0),
|
||||
(1, 0, 0),
|
||||
(1, 1, 0),
|
||||
(0, 1, 0),
|
||||
(0, 0, 1),
|
||||
(1, 0, 1),
|
||||
(1, 1, 1),
|
||||
(0, 1, 1),
|
||||
]
|
||||
|
||||
# 6 cube faces
|
||||
cube_faces = [
|
||||
[0, 3, 2, 1],
|
||||
[4, 5, 6, 7],
|
||||
[0, 1, 5, 4],
|
||||
[1, 2, 6, 5],
|
||||
[3, 7, 6, 2],
|
||||
[0, 4, 7, 3],
|
||||
]
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
||||
class MengerSponge:
|
||||
"""
|
||||
|
||||
Args:
|
||||
location: location of lower left corner as (x, y, z) tuple
|
||||
length: side length
|
||||
level: subdivide level
|
||||
kind: type of menger sponge
|
||||
|
||||
=== ===========================
|
||||
0 Original Menger Sponge
|
||||
1 Variant XOX
|
||||
2 Variant OXO
|
||||
3 Jerusalem Cube
|
||||
=== ===========================
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
location: UVec = (0.0, 0.0, 0.0),
|
||||
length: float = 1.0,
|
||||
level: int = 1,
|
||||
kind: int = 0,
|
||||
):
|
||||
self.cube_definitions = _menger_sponge(
|
||||
location=location, length=length, level=level, kind=kind
|
||||
)
|
||||
|
||||
def vertices(self) -> Iterator[list[Vec3]]:
|
||||
"""Yields the cube vertices as list of (x, y, z) tuples."""
|
||||
for location, length in self.cube_definitions:
|
||||
x, y, z = location
|
||||
yield [
|
||||
Vec3(x + xf * length, y + yf * length, z + zf * length)
|
||||
for xf, yf, zf in _cube_vertices
|
||||
]
|
||||
|
||||
__iter__ = vertices
|
||||
|
||||
@staticmethod
|
||||
def faces() -> list[list[int]]:
|
||||
"""Returns list of cube faces. All cube vertices have the same order, so
|
||||
one faces list fits them all.
|
||||
|
||||
"""
|
||||
return cube_faces
|
||||
|
||||
def render(
|
||||
self,
|
||||
layout: GenericLayoutType,
|
||||
merge: bool = False,
|
||||
dxfattribs=None,
|
||||
matrix: Optional[Matrix44] = None,
|
||||
ucs: Optional[UCS] = None,
|
||||
) -> None:
|
||||
"""Renders the menger sponge into layout, set `merge` to ``True`` for
|
||||
rendering the whole menger sponge into one MESH entity, set `merge` to
|
||||
``False`` for rendering the individual cubes of the menger sponge as
|
||||
MESH entities.
|
||||
|
||||
Args:
|
||||
layout: DXF target layout
|
||||
merge: ``True`` for one MESH entity, ``False`` for individual MESH
|
||||
entities per cube
|
||||
dxfattribs: DXF attributes for the MESH entities
|
||||
matrix: apply transformation matrix at rendering
|
||||
ucs: apply UCS transformation at rendering
|
||||
|
||||
"""
|
||||
if merge:
|
||||
mesh = self.mesh()
|
||||
mesh.render_mesh(
|
||||
layout, dxfattribs=dxfattribs, matrix=matrix, ucs=ucs
|
||||
)
|
||||
else:
|
||||
for cube in self.cubes():
|
||||
cube.render_mesh(layout, dxfattribs, matrix=matrix, ucs=ucs)
|
||||
|
||||
def cubes(self) -> Iterator[MeshTransformer]:
|
||||
"""Yields all cubes of the menger sponge as individual
|
||||
:class:`MeshTransformer` objects.
|
||||
"""
|
||||
faces = self.faces()
|
||||
for vertices in self:
|
||||
mesh = MeshVertexMerger()
|
||||
mesh.add_mesh(vertices=vertices, faces=faces) # type: ignore
|
||||
yield MeshTransformer.from_builder(mesh)
|
||||
|
||||
def mesh(self) -> MeshTransformer:
|
||||
"""Returns geometry as one :class:`MeshTransformer` object."""
|
||||
faces = self.faces()
|
||||
mesh = MeshVertexMerger()
|
||||
for vertices in self:
|
||||
mesh.add_mesh(vertices=vertices, faces=faces) # type: ignore
|
||||
return remove_duplicate_inner_faces(mesh)
|
||||
|
||||
|
||||
def remove_duplicate_inner_faces(mesh: MeshBuilder) -> MeshTransformer:
|
||||
new_mesh = MeshTransformer()
|
||||
new_mesh.vertices = mesh.vertices
|
||||
new_mesh.faces = list(manifold_faces(mesh.faces))
|
||||
return new_mesh
|
||||
|
||||
|
||||
def manifold_faces(faces: list[Sequence[int]]) -> Iterator[Sequence[int]]:
|
||||
ledger: dict[tuple[int, ...], list[Sequence[int]]] = {}
|
||||
for face in faces:
|
||||
key = tuple(sorted(face))
|
||||
try:
|
||||
ledger[key].append(face)
|
||||
except KeyError:
|
||||
ledger[key] = [face]
|
||||
for faces in ledger.values():
|
||||
if len(faces) == 1:
|
||||
yield faces[0]
|
||||
|
||||
|
||||
def _subdivide(
|
||||
location: UVec = (0.0, 0.0, 0.0), length: float = 1.0, kind: int = 0
|
||||
) -> list[tuple[Vec3, float]]:
|
||||
"""Divides a cube in sub-cubes and keeps only cubes determined by the
|
||||
building schema.
|
||||
|
||||
All sides are parallel to x-, y- and z-axis, location is a (x, y, z) tuple
|
||||
and represents the coordinates of the lower left corner (nearest to the axis
|
||||
origin) of the cube, length is the side-length of the cube
|
||||
|
||||
Args:
|
||||
location: (x, y, z) tuple, coordinates of the lower left corner of the cube
|
||||
length: side length of the cube
|
||||
kind: int for 0: original menger sponge; 1: Variant XOX; 2: Variant OXO;
|
||||
3: Jerusalem Cube;
|
||||
|
||||
Returns: list of sub-cubes (location, length)
|
||||
|
||||
"""
|
||||
|
||||
init_x, init_y, init_z = location
|
||||
step_size = float(length) / cube_sizes[kind]
|
||||
remaining_cubes = building_schemas[kind]
|
||||
|
||||
def sub_location(indices) -> Vec3:
|
||||
x, y, z = indices
|
||||
return Vec3(
|
||||
init_x + x * step_size,
|
||||
init_y + y * step_size,
|
||||
init_z + z * step_size,
|
||||
)
|
||||
|
||||
return [(sub_location(indices), step_size) for indices in remaining_cubes]
|
||||
|
||||
|
||||
def _menger_sponge(
|
||||
location: UVec = (0.0, 0.0, 0.0),
|
||||
length: float = 1.0,
|
||||
level: int = 1,
|
||||
kind: int = 0,
|
||||
) -> list[tuple[Vec3, float]]:
|
||||
"""Builds a menger sponge for given level.
|
||||
|
||||
Args:
|
||||
location: (x, y, z) tuple, coordinates of the lower left corner of the cube
|
||||
length: side length of the cube
|
||||
level: level of menger sponge, has to be 1 or bigger
|
||||
kind: int for 0: original menger sponge; 1: Variant XOX; 2: Variant OXO;
|
||||
3: Jerusalem Cube;
|
||||
|
||||
Returns: list of sub-cubes (location, length)
|
||||
|
||||
"""
|
||||
kind = int(kind)
|
||||
if kind not in (0, 1, 2, 3):
|
||||
raise ValueError("kind has to be 0, 1, 2 or 3.")
|
||||
level = int(level)
|
||||
if level < 1:
|
||||
raise ValueError("level has to be 1 or bigger.")
|
||||
cubes = _subdivide(location, length, kind=kind)
|
||||
for _ in range(level - 1):
|
||||
next_level_cubes = []
|
||||
for location, length in cubes:
|
||||
next_level_cubes.extend(_subdivide(location, length, kind=kind))
|
||||
cubes = next_level_cubes
|
||||
return cubes
|
||||
@@ -0,0 +1,675 @@
|
||||
# Copyright (c) 2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Union, Sequence
|
||||
import os
|
||||
import struct
|
||||
import uuid
|
||||
import datetime
|
||||
import enum
|
||||
import zipfile
|
||||
|
||||
from ezdxf.math import Vec3, normal_vector_3p, BoundingBox
|
||||
from ezdxf.render import MeshTransformer, MeshVertexMerger, MeshBuilder
|
||||
from ezdxf import __version__
|
||||
|
||||
|
||||
class UnsupportedFileFormat(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ParsingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def stl_readfile(filename: Union[str, os.PathLike]) -> MeshTransformer:
|
||||
"""Read ascii or binary `STL`_ file content as :class:`ezdxf.render.MeshTransformer`
|
||||
instance.
|
||||
|
||||
Raises:
|
||||
ParsingError: vertex parsing error or invalid/corrupt data
|
||||
|
||||
"""
|
||||
with open(filename, "rb") as fp:
|
||||
buffer = fp.read()
|
||||
if buffer.startswith(b"solid"):
|
||||
s = buffer.decode("ascii", errors="ignore")
|
||||
return stl_loads(s)
|
||||
else:
|
||||
return stl_loadb(buffer)
|
||||
|
||||
|
||||
def stl_loads(content: str) -> MeshTransformer:
|
||||
"""Load a mesh from an ascii `STL`_ content string as :class:`ezdxf.render.MeshTransformer`
|
||||
instance.
|
||||
|
||||
Raises:
|
||||
ParsingError: vertex parsing error
|
||||
|
||||
"""
|
||||
# http://www.fabbers.com/tech/STL_Format#Sct_ASCII
|
||||
# This implementation is not very picky and grabs only lines which start
|
||||
# with "vertex" or "endloop" and ignores the rest.
|
||||
def parse_vertex(line: str) -> Vec3:
|
||||
data = line.split()
|
||||
return Vec3(float(data[1]), float(data[2]), float(data[3]))
|
||||
|
||||
mesh = MeshVertexMerger()
|
||||
face: list[Vec3] = []
|
||||
for num, line in enumerate(content.split("\n"), start=1):
|
||||
line = line.strip(" \r")
|
||||
if line.startswith("vertex"):
|
||||
try:
|
||||
face.append(parse_vertex(line))
|
||||
except (IndexError, ValueError):
|
||||
raise ParsingError(f"STL parsing error in line {num}: {line}")
|
||||
elif line.startswith("endloop"):
|
||||
if len(face) == 3:
|
||||
mesh.add_face(face)
|
||||
face.clear()
|
||||
return MeshTransformer.from_builder(mesh)
|
||||
|
||||
|
||||
def stl_loadb(buffer: bytes) -> MeshTransformer:
|
||||
"""Load a mesh from a binary `STL`_ data :class:`ezdxf.render.MeshTransformer`
|
||||
instance.
|
||||
|
||||
Raises:
|
||||
ParsingError: invalid/corrupt data or not a binary STL file
|
||||
|
||||
"""
|
||||
# http://www.fabbers.com/tech/STL_Format#Sct_ASCII
|
||||
index = 80
|
||||
n_faces = struct.unpack_from("<I", buffer, index)[0]
|
||||
index += 4
|
||||
|
||||
mesh = MeshVertexMerger()
|
||||
for _ in range(n_faces):
|
||||
try:
|
||||
face = struct.unpack_from("<12fH", buffer, index)
|
||||
except struct.error:
|
||||
raise ParsingError("binary STL parsing error")
|
||||
index += 50
|
||||
v1 = Vec3(face[3:6])
|
||||
v2 = Vec3(face[6:9])
|
||||
v3 = Vec3(face[9:12])
|
||||
mesh.add_face((v1, v2, v3))
|
||||
return MeshTransformer.from_builder(mesh)
|
||||
|
||||
|
||||
def off_readfile(filename: Union[str, os.PathLike]) -> MeshTransformer:
|
||||
"""Read `OFF`_ file content as :class:`ezdxf.render.MeshTransformer`
|
||||
instance.
|
||||
|
||||
Raises:
|
||||
ParsingError: vertex or face parsing error
|
||||
|
||||
"""
|
||||
with open(filename, "rt", encoding="ascii", errors="ignore") as fp:
|
||||
content = fp.read()
|
||||
return off_loads(content)
|
||||
|
||||
|
||||
def off_loads(content: str) -> MeshTransformer:
|
||||
"""Load a mesh from a `OFF`_ content string as :class:`ezdxf.render.MeshTransformer`
|
||||
instance.
|
||||
|
||||
Raises:
|
||||
ParsingError: vertex or face parsing error
|
||||
|
||||
"""
|
||||
# https://en.wikipedia.org/wiki/OFF_(file_format)
|
||||
mesh = MeshVertexMerger()
|
||||
lines: list[str] = []
|
||||
for line in content.split("\n"):
|
||||
line = line.strip(" \n\r")
|
||||
# "OFF" in a single line
|
||||
if line.startswith("#") or line == "OFF" or line == "":
|
||||
continue
|
||||
lines.append(line)
|
||||
|
||||
if len(lines) == 0:
|
||||
raise ParsingError(f"OFF format parsing error: no data")
|
||||
|
||||
if lines[0].startswith("OFF"):
|
||||
# OFF v f e
|
||||
lines[0] = lines[0][4:]
|
||||
|
||||
n = lines[0].split()
|
||||
try:
|
||||
n_vertices, n_faces = int(n[0]), int(n[1])
|
||||
except ValueError:
|
||||
raise ParsingError(f"OFF format parsing error: {lines[0]}")
|
||||
|
||||
if len(lines) < n_vertices + n_faces:
|
||||
raise ParsingError(f"OFF format parsing error: invalid data count")
|
||||
|
||||
for vertex in lines[1 : n_vertices + 1]:
|
||||
v = vertex.split()
|
||||
try:
|
||||
vtx = Vec3(float(v[0]), float(v[1]), float(v[2]))
|
||||
except (ValueError, IndexError):
|
||||
raise ParsingError(f"OFF format vertex parsing error: {vertex}")
|
||||
mesh.vertices.append(vtx)
|
||||
|
||||
index = n_vertices + 1
|
||||
face_indices = []
|
||||
for face in lines[index : index + n_faces]:
|
||||
f = face.split()
|
||||
try:
|
||||
vertex_count = int(f[0])
|
||||
except ValueError:
|
||||
raise ParsingError(f"OFF format face parsing error: {face}")
|
||||
for index in range(vertex_count):
|
||||
try:
|
||||
face_indices.append(int(f[1 + index]))
|
||||
except (ValueError, IndexError):
|
||||
raise ParsingError(
|
||||
f"OFF format face index parsing error: {face}"
|
||||
)
|
||||
mesh.faces.append(tuple(face_indices))
|
||||
face_indices.clear()
|
||||
return MeshTransformer.from_builder(mesh)
|
||||
|
||||
|
||||
def obj_readfile(filename: Union[str, os.PathLike]) -> list[MeshTransformer]:
|
||||
"""Read `OBJ`_ file content as list of :class:`ezdxf.render.MeshTransformer`
|
||||
instances.
|
||||
|
||||
Raises:
|
||||
ParsingError: vertex or face parsing error
|
||||
|
||||
"""
|
||||
with open(filename, "rt", encoding="ascii", errors="ignore") as fp:
|
||||
content = fp.read()
|
||||
return obj_loads(content)
|
||||
|
||||
|
||||
def obj_loads(content: str) -> list[MeshTransformer]:
|
||||
"""Load one or more meshes from an `OBJ`_ content string as list of
|
||||
:class:`ezdxf.render.MeshTransformer` instances.
|
||||
|
||||
Raises:
|
||||
ParsingError: vertex parsing error
|
||||
|
||||
"""
|
||||
# https://en.wikipedia.org/wiki/Wavefront_.obj_file
|
||||
# This implementation is not very picky and grabs only lines which start
|
||||
# with "v", "g" or "f" and ignores the rest.
|
||||
def parse_vertex(l: str) -> Vec3:
|
||||
v = l.split()
|
||||
return Vec3(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
def parse_face(l: str) -> Sequence[int]:
|
||||
return tuple(int(s.split("/")[0]) for s in l.split())
|
||||
|
||||
vertices: list[Vec3] = [Vec3()] # 1-indexed
|
||||
meshes: list[MeshTransformer] = []
|
||||
mesh = MeshVertexMerger()
|
||||
for num, line in enumerate(content.split("\n"), start=1):
|
||||
line = line.strip(" \r")
|
||||
if line.startswith("v"):
|
||||
try:
|
||||
vtx = parse_vertex(line[2:])
|
||||
except (IndexError, ValueError):
|
||||
raise ParsingError(
|
||||
f"OBJ vertex parsing error in line {num}: {line}"
|
||||
)
|
||||
vertices.append(vtx)
|
||||
elif line.startswith("f"):
|
||||
try:
|
||||
mesh.add_face(vertices[i] for i in parse_face(line[2:]))
|
||||
except ValueError:
|
||||
raise ParsingError(
|
||||
f"OBJ face parsing error in line {num}: {line}"
|
||||
)
|
||||
except IndexError:
|
||||
raise ParsingError(
|
||||
f"OBJ face index error (n={len(vertices)}) in line {num}: {line}"
|
||||
)
|
||||
|
||||
elif line.startswith("g") and len(mesh.vertices) > 0:
|
||||
meshes.append(MeshTransformer.from_builder(mesh))
|
||||
mesh = MeshVertexMerger()
|
||||
|
||||
if len(mesh.vertices) > 0:
|
||||
meshes.append(MeshTransformer.from_builder(mesh))
|
||||
return meshes
|
||||
|
||||
|
||||
def stl_dumps(mesh: MeshBuilder) -> str:
|
||||
"""Returns the `STL`_ data as string for the given `mesh`.
|
||||
This function triangulates the meshes automatically because the `STL`_
|
||||
format supports only triangles as faces.
|
||||
|
||||
This function does not check if the mesh obey the
|
||||
`STL`_ format `rules <http://www.fabbers.com/tech/STL_Format>`_:
|
||||
|
||||
- The direction of the face normal is outward.
|
||||
- The face vertices are listed in counter-clockwise order when looking
|
||||
at the object from the outside (right-hand rule).
|
||||
- Each triangle must share two vertices with each of its adjacent triangles.
|
||||
- The object represented must be located in the all-positive octant
|
||||
(non-negative and nonzero).
|
||||
|
||||
"""
|
||||
lines: list[str] = [f"solid STL generated by ezdxf {__version__}"]
|
||||
for face in mesh.tessellation(max_vertex_count=3):
|
||||
if len(face) < 3:
|
||||
continue
|
||||
try:
|
||||
n = normal_vector_3p(face[0], face[1], face[2])
|
||||
except ZeroDivisionError:
|
||||
continue
|
||||
n = n.round(8)
|
||||
lines.append(f" facet normal {n.x} {n.y} {n.z}")
|
||||
lines.append(" outer loop")
|
||||
for v in face:
|
||||
lines.append(f" vertex {v.x} {v.y} {v.z}")
|
||||
lines.append(" endloop")
|
||||
lines.append(" endfacet")
|
||||
lines.append("endsolid\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
STL_SIGNATURE = (b"STL generated ezdxf" + b" " * 80)[:80]
|
||||
|
||||
|
||||
def stl_dumpb(mesh: MeshBuilder) -> bytes:
|
||||
"""Returns the `STL`_ binary data as bytes for the given `mesh`.
|
||||
|
||||
For more information see function: :func:`stl_dumps`
|
||||
"""
|
||||
data: list[bytes] = [STL_SIGNATURE, b"0000"]
|
||||
count = 0
|
||||
for face in mesh.tessellation(max_vertex_count=3):
|
||||
try:
|
||||
n = normal_vector_3p(face[0], face[1], face[2])
|
||||
except ZeroDivisionError:
|
||||
continue
|
||||
count += 1
|
||||
values = list(n.xyz)
|
||||
for v in face:
|
||||
values.extend(v.xyz)
|
||||
values.append(0)
|
||||
data.append(struct.pack("<12fH", *values))
|
||||
data[1] = struct.pack("<I", count)
|
||||
return b"".join(data)
|
||||
|
||||
|
||||
def off_dumps(mesh: MeshBuilder) -> str:
|
||||
"""Returns the `OFF`_ data as string for the given `mesh`.
|
||||
The `OFF`_ format supports ngons as faces.
|
||||
|
||||
"""
|
||||
lines: list[str] = ["OFF", f"{len(mesh.vertices)} {len(mesh.faces)} 0"]
|
||||
for v in mesh.vertices:
|
||||
v = v.round(6)
|
||||
lines.append(f"{v.x} {v.y} {v.z}")
|
||||
for face in mesh.open_faces():
|
||||
lines.append(f"{len(face)} {' '.join(str(i) for i in face)}")
|
||||
lines[-1] += "\n"
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def obj_dumps(mesh: MeshBuilder) -> str:
|
||||
"""Returns the `OBJ`_ data as string for the given `mesh`.
|
||||
The `OBJ`_ format supports ngons as faces.
|
||||
|
||||
"""
|
||||
lines: list[str] = [f"# OBJ generated by ezdxf {__version__}"]
|
||||
for v in mesh.vertices:
|
||||
v = v.round(6)
|
||||
lines.append(f"v {v.x} {v.y} {v.z}")
|
||||
for face in mesh.open_faces():
|
||||
# OBJ is 1-index
|
||||
lines.append("f " + " ".join(str(i + 1) for i in face))
|
||||
lines[-1] += "\n"
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def scad_dumps(mesh: MeshBuilder) -> str:
|
||||
"""Returns the `OpenSCAD`_ `polyhedron`_ definition as string for the given
|
||||
`mesh`. `OpenSCAD`_ supports ngons as faces.
|
||||
|
||||
.. Important::
|
||||
|
||||
`OpenSCAD`_ requires the face normals pointing inwards, the method
|
||||
:meth:`~ezdxf.render.MeshBuilder.flip_normals` of the
|
||||
:class:`~ezdxf.render.MeshBuilder` class can flip the normals
|
||||
inplace.
|
||||
|
||||
"""
|
||||
# polyhedron( points = [ [X0, Y0, Z0], [X1, Y1, Z1], ... ], faces = [ [P0, P1, P2, P3, ...], ... ], convexity = N); // 2014.03 & later
|
||||
lines: list[str] = ["polyhedron(points = ["]
|
||||
for v in mesh.vertices:
|
||||
v = v.round(6)
|
||||
lines.append(f" [{v.x}, {v.y}, {v.z}],")
|
||||
# OpenSCAD accept the last ","
|
||||
lines.append("], faces = [")
|
||||
for face in mesh.open_faces():
|
||||
lines.append(" [" + ", ".join(str(i) for i in face) + "],")
|
||||
# OpenSCAD accept the last ","
|
||||
lines.append("], convexity = 10);\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ply_dumpb(mesh: MeshBuilder) -> bytes:
|
||||
"""Returns the `PLY`_ binary data as bytes for the given `mesh`.
|
||||
The `PLY`_ format supports ngons as faces.
|
||||
|
||||
"""
|
||||
if any(len(f) > 255 for f in mesh.faces):
|
||||
face_hdr_fmt = b"property list int int vertex_index"
|
||||
face_fmt = "<i{}i"
|
||||
else:
|
||||
face_hdr_fmt = b"property list uchar int vertex_index"
|
||||
face_fmt = "<B{}i"
|
||||
|
||||
header: bytes = b"\n".join(
|
||||
[
|
||||
b"ply",
|
||||
b"format binary_little_endian 1.0",
|
||||
b"comment generated by ezdxf " + __version__.encode(),
|
||||
b"element vertex " + str(len(mesh.vertices)).encode(),
|
||||
b"property float x",
|
||||
b"property float y",
|
||||
b"property float z",
|
||||
b"element face " + str(len(mesh.faces)).encode(),
|
||||
face_hdr_fmt,
|
||||
b"end_header\n",
|
||||
]
|
||||
)
|
||||
data: list[bytes] = [header]
|
||||
for vertex in mesh.vertices:
|
||||
data.append(struct.pack("<3f", vertex.x, vertex.y, vertex.z))
|
||||
for face in mesh.open_faces():
|
||||
count = len(face)
|
||||
fmt = face_fmt.format(count)
|
||||
data.append(struct.pack(fmt, count, *face))
|
||||
return b"".join(data)
|
||||
|
||||
|
||||
def ifc_guid() -> str:
|
||||
return _guid_compress(uuid.uuid4().hex)
|
||||
|
||||
|
||||
def _guid_compress(g: str) -> str:
|
||||
# https://github.com/IfcOpenShell/IfcOpenShell/blob/master/src/ifcopenshell-python/ifcopenshell/guid.py#L56
|
||||
chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$"
|
||||
bs = [int(g[i : i + 2], 16) for i in range(0, len(g), 2)]
|
||||
|
||||
def b64(v: int, size: int = 4):
|
||||
return "".join(
|
||||
[chars[(v // (64**i)) % 64] for i in range(size)][::-1]
|
||||
)
|
||||
|
||||
return "".join(
|
||||
[b64(bs[0], 2)]
|
||||
+ [
|
||||
b64((bs[i] << 16) + (bs[i + 1] << 8) + bs[i + 2])
|
||||
for i in range(1, 16, 3)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class IfcEntityType(enum.Enum):
|
||||
POLYGON_FACE_SET = "POLY_FACE_SET"
|
||||
CLOSED_SHELL = "CLOSED_SHELL"
|
||||
OPEN_SHELL = "OPEN_SHELL"
|
||||
|
||||
|
||||
class Records:
|
||||
def __init__(self) -> None:
|
||||
# Record numbers are 1-based, record index in list is 0-based!
|
||||
# records[0] is record #1
|
||||
self.records: list[str] = []
|
||||
|
||||
@property
|
||||
def last_num(self) -> int: # list index
|
||||
return len(self.records)
|
||||
|
||||
@property
|
||||
def prev_num(self) -> int: # list index
|
||||
return self.last_num - 1
|
||||
|
||||
@property
|
||||
def next_num(self) -> int: # list index
|
||||
return self.last_num + 1
|
||||
|
||||
def add(self, record: str, num: int = 0) -> str:
|
||||
assert record.endswith(");"), "invalid structure"
|
||||
self.records.append(record)
|
||||
if num != 0 and num != len(self.records):
|
||||
raise ValueError("unexpected record number")
|
||||
num = len(self.records)
|
||||
return f"#{num}"
|
||||
|
||||
def add_dummy(self):
|
||||
self.records.append("")
|
||||
|
||||
def get(self, num: int) -> str:
|
||||
return self.records[num - 1]
|
||||
|
||||
def update_all(self, tag: str, record_num: str):
|
||||
for index, record in enumerate(self.records):
|
||||
if tag in record:
|
||||
self.update_record(index, tag, record_num)
|
||||
|
||||
def update_record(self, index, tag: str, record_num: str):
|
||||
self.records[index] = self.records[index].replace(tag, record_num)
|
||||
|
||||
def dumps(self) -> str:
|
||||
return "\n".join(
|
||||
f"#{num+1}= {data}" for num, data in enumerate(self.records) if data
|
||||
)
|
||||
|
||||
|
||||
def ifc4_dumps(
|
||||
mesh: MeshBuilder,
|
||||
entity_type=IfcEntityType.POLYGON_FACE_SET,
|
||||
*,
|
||||
layer: str = "MeshExport",
|
||||
color: tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||
) -> str:
|
||||
"""Returns the `IFC4`_ string for the given `mesh`. The caller is
|
||||
responsible for checking if the mesh is a closed or open surface
|
||||
(e.g. :code:`mesh.diagnose().euler_characteristic == 2`) and using the
|
||||
appropriate entity type.
|
||||
|
||||
Args:
|
||||
mesh: :class:`~ezdxf.render.MeshBuilder`
|
||||
entity_type: :class:`IfcEntityType`
|
||||
layer: layer name as string
|
||||
color: entity color as RGB tuple, values in the range [0,1]
|
||||
|
||||
.. warning::
|
||||
|
||||
`IFC4`_ is a very complex data format and this is a minimal effort
|
||||
exporter, so the exported data may not be importable by all CAD
|
||||
applications.
|
||||
|
||||
The exported `IFC4`_ data can be imported by the following applications:
|
||||
|
||||
- BricsCAD
|
||||
- FreeCAD (IfcOpenShell)
|
||||
- Allplan
|
||||
- Tekla BIMsight
|
||||
|
||||
"""
|
||||
|
||||
def make_header():
|
||||
date = datetime.datetime.now().isoformat()[:-7]
|
||||
return f"""ISO-10303-21;
|
||||
HEADER;
|
||||
FILE_DESCRIPTION(('ViewDefinition [CoordinationView_V2.0]'),'2;1');
|
||||
FILE_NAME('undefined.ifc','{date}',('Undefined'),('Undefined'),'ezdxf {__version__}','ezdxf {__version__}','Undefined');
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
|
||||
DATA;
|
||||
"""
|
||||
|
||||
def make_data_records() -> Records:
|
||||
records = Records()
|
||||
# fmt: off
|
||||
if entity_type == IfcEntityType.POLYGON_FACE_SET:
|
||||
kind = "SurfaceModel"
|
||||
elif entity_type == IfcEntityType.OPEN_SHELL:
|
||||
kind = "SurfaceModel"
|
||||
elif entity_type == IfcEntityType.CLOSED_SHELL:
|
||||
kind = "Brep"
|
||||
else:
|
||||
raise ValueError(f"invalid entity type: {entity_type}")
|
||||
# for simplicity the first part has absolute record numbering:
|
||||
records.add(f"IFCPROJECT('{ifc_guid()}',#2,'MeshExport',$,$,$,$,(#7),#13);", 1)
|
||||
records.add("IFCOWNERHISTORY(#3,#6,$,$,$,$,$,0);", 2)
|
||||
records.add("IFCPERSONANDORGANIZATION(#4,#5,$);", 3)
|
||||
records.add("IFCPERSON($,$,'Undefined',$,$,$,$,$);", 4)
|
||||
records.add("IFCORGANIZATION($,'Undefined',$,$,$);", 5)
|
||||
records.add(f"IFCAPPLICATION(#5,'{__version__}','ezdxf','ezdxf');", 6)
|
||||
records.add("IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.000000000000000E-05,#8,#12);", 7)
|
||||
records.add("IFCAXIS2PLACEMENT3D(#9,#10,#11);", 8)
|
||||
records.add("IFCCARTESIANPOINT((0.,0.,0.));", 9)
|
||||
records.add("IFCDIRECTION((0.,0.,1.));", 10)
|
||||
records.add("IFCDIRECTION((1.,0.,0.));", 11)
|
||||
records.add("IFCDIRECTION((1.,0.));", 12)
|
||||
records.add("IFCUNITASSIGNMENT((#14,#15,#16,#17,#18,#19));", 13)
|
||||
records.add("IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);", 14)
|
||||
records.add("IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);", 15)
|
||||
records.add("IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);", 16)
|
||||
records.add("IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);", 17)
|
||||
records.add("IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.);", 18)
|
||||
records.add("IFCSIUNIT(*,.MASSUNIT.,$,.GRAM.);", 19)
|
||||
records.add("IFCLOCALPLACEMENT($,#8);", 20)
|
||||
building = records.add(f"IFCBUILDING('{ifc_guid()}',#2,'MeshExport',$,$, #20,$,$,.ELEMENT.,$,$,$);", 21)
|
||||
records.add_dummy() # I don't wanna redo the absolute record numbering
|
||||
proxy = records.add(f"IFCBUILDINGELEMENTPROXY('{ifc_guid()}',#2,$,$,$,#24,#29,$,$);", 23)
|
||||
records.add("IFCLOCALPLACEMENT(#25,#26);", 24)
|
||||
records.add("IFCLOCALPLACEMENT(#20,#8);", 25)
|
||||
records.add("IFCAXIS2PLACEMENT3D(#27,#10,#28);", 26)
|
||||
records.add(f"IFCCARTESIANPOINT(({emin.x},{emin.y},{emin.z}));", 27)
|
||||
records.add("IFCDIRECTION((1.,0.,0.));", 28)
|
||||
records.add("IFCPRODUCTDEFINITIONSHAPE($,$,(#30));", 29)
|
||||
shape = records.add(f"IFCSHAPEREPRESENTATION(#31,'Body','{kind}',($ENTITY$));", 30)
|
||||
records.add("IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#7,$,.MODEL_VIEW.,$);", 31)
|
||||
|
||||
# from here on only relative record numbering:
|
||||
entity = "#0"
|
||||
if entity_type == IfcEntityType.POLYGON_FACE_SET:
|
||||
entity = make_polygon_face_set(records)
|
||||
elif entity_type == IfcEntityType.CLOSED_SHELL:
|
||||
entity = make_shell(records)
|
||||
elif entity_type == IfcEntityType.OPEN_SHELL:
|
||||
entity = make_shell(records)
|
||||
color_str = f"IFCCOLOURRGB($,{color[0]:.3f},{color[1]:.3f},{color[2]:.3f});"
|
||||
records.add(color_str)
|
||||
records.add(f"IFCSURFACESTYLESHADING(#{records.prev_num+1},0.);")
|
||||
records.add(f"IFCSURFACESTYLE($,.POSITIVE.,(#{records.prev_num+1}));")
|
||||
records.add(f"IFCPRESENTATIONSTYLEASSIGNMENT((#{records.prev_num+1}));")
|
||||
records.add(f"IFCSTYLEDITEM($ENTITY$,(#{records.prev_num+1}),$);")
|
||||
records.add(f"IFCPRESENTATIONLAYERWITHSTYLE('{layer}',$,({shape}),$,.T.,.F.,.F.,(#{records.next_num+1}));")
|
||||
records.add(f"IFCSURFACESTYLE($,.POSITIVE.,(#{records.next_num+1}));")
|
||||
records.add(f"IFCSURFACESTYLESHADING(#{records.next_num+1},0.);")
|
||||
records.add(color_str)
|
||||
records.add(f"IFCRELCONTAINEDINSPATIALSTRUCTURE('{ifc_guid()}',#2,$,$,({proxy}),{building});")
|
||||
records.add(f"IFCRELAGGREGATES('{ifc_guid()}',#2,$,$,#1,({building}));")
|
||||
# fmt: on
|
||||
records.update_all("$ENTITY$", entity)
|
||||
return records
|
||||
|
||||
def make_polygon_face_set(records: Records) -> str:
|
||||
entity = records.add(
|
||||
f"IFCPOLYGONALFACESET(#{records.next_num+1},$,($FACES$), $);"
|
||||
)
|
||||
entity_num = records.last_num
|
||||
vertices = ",".join([str(v.xyz) for v in mesh.vertices])
|
||||
records.add(f"IFCCARTESIANPOINTLIST3D(({vertices}));")
|
||||
face_records: list[str] = []
|
||||
for face in mesh.open_faces():
|
||||
indices = ",".join(str(i + 1) for i in face) # 1-based indexing
|
||||
face_records.append(
|
||||
records.add(f"IFCINDEXEDPOLYGONALFACE(({indices}));")
|
||||
)
|
||||
# list index required
|
||||
records.update_record(entity_num - 1, "$FACES$", ",".join(face_records))
|
||||
return entity
|
||||
|
||||
def make_shell(records: Records) -> str:
|
||||
if entity_type == IfcEntityType.CLOSED_SHELL:
|
||||
entity = records.add(f"IFCFACETEDBREP(#{records.next_num+1});")
|
||||
records.add(f"IFCCLOSEDSHELL(($FACES$));")
|
||||
elif entity_type == IfcEntityType.OPEN_SHELL:
|
||||
entity = records.add(
|
||||
f"IFCSHELLBASEDSURFACEMODEL((#{records.next_num + 1}));"
|
||||
)
|
||||
records.add(f"IFCOPENSHELL(($FACES$));")
|
||||
else:
|
||||
raise ValueError(f"invalid entity type: {entity_type}")
|
||||
shell_num = records.last_num
|
||||
# add vertices
|
||||
first_vertex = records.next_num
|
||||
for v in mesh.vertices:
|
||||
records.add(f"IFCCARTESIANPOINT({str(v.xyz)});")
|
||||
# add faces
|
||||
face_records: list[str] = []
|
||||
for face in mesh.open_faces():
|
||||
vertices = ",".join("#" + str(first_vertex + i) for i in face)
|
||||
records.add(f"IFCPOLYLOOP(({vertices}));")
|
||||
records.add(f"IFCFACEOUTERBOUND(#{records.prev_num+1},.T.);")
|
||||
face_records.append(
|
||||
records.add(f"IFCFACE((#{records.prev_num+1}));")
|
||||
)
|
||||
# list index required
|
||||
records.update_record(shell_num - 1, "$FACES$", ",".join(face_records))
|
||||
return entity
|
||||
|
||||
if len(mesh.vertices) == 0:
|
||||
return ""
|
||||
bbox = BoundingBox(mesh.vertices)
|
||||
emin = Vec3()
|
||||
assert bbox.extmin is not None
|
||||
if bbox.extmin.x < 0 or bbox.extmin.y < 0 or bbox.extmin.z < 0:
|
||||
# Allplan (IFC?) requires all mesh vertices in the all-positive octant
|
||||
# (non-negative). Record #27 does the final placement at the correct
|
||||
# location.
|
||||
emin = bbox.extmin
|
||||
mesh = MeshTransformer.from_builder(mesh)
|
||||
mesh.translate(-emin.x, -emin.y, -emin.z)
|
||||
|
||||
header = make_header()
|
||||
data = make_data_records()
|
||||
return header + data.dumps() + "\nENDSEC;\nEND-ISO-10303-21;\n"
|
||||
|
||||
|
||||
def export_ifcZIP(
|
||||
filename: Union[str, os.PathLike],
|
||||
mesh: MeshBuilder,
|
||||
entity_type=IfcEntityType.POLYGON_FACE_SET,
|
||||
*,
|
||||
layer: str = "MeshExport",
|
||||
color: tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||
):
|
||||
"""Export the given `mesh` as zip-compressed `IFC4`_ file. The filename
|
||||
suffix should be ``.ifcZIP``. For more information see function
|
||||
:func:`ifc4_dumps`.
|
||||
|
||||
Args:
|
||||
filename: zip filename, the data file has the same name with suffix ``.ifc``
|
||||
mesh: :class:`~ezdxf.render.MeshBuilder`
|
||||
entity_type: :class:`IfcEntityType`
|
||||
layer: layer name as string
|
||||
color: entity color as RGB tuple, values in the range [0,1]
|
||||
|
||||
Raises:
|
||||
IOError: IO error when opening the zip-file for writing
|
||||
|
||||
"""
|
||||
name = os.path.basename(filename) + ".ifc"
|
||||
zf = zipfile.ZipFile(filename, mode="w", compression=zipfile.ZIP_DEFLATED)
|
||||
try:
|
||||
zf.writestr(
|
||||
name, ifc4_dumps(mesh, entity_type, layer=layer, color=color)
|
||||
)
|
||||
finally:
|
||||
zf.close()
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2011, Manfred Moitzi
|
||||
# License: MIT License
|
||||
|
||||
|
||||
class SubscriptAttributes:
|
||||
def __getitem__(self, item):
|
||||
if hasattr(self, item):
|
||||
return getattr(self, item)
|
||||
else:
|
||||
raise KeyError(item)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
else:
|
||||
raise KeyError(key)
|
||||
|
||||
def __contains__(self, item):
|
||||
return hasattr(self, item)
|
||||
@@ -0,0 +1,174 @@
|
||||
# Copyright (c) 2010-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import math
|
||||
import enum
|
||||
|
||||
|
||||
from ezdxf.enums import (
|
||||
MTextEntityAlignment,
|
||||
MAP_MTEXT_ALIGN_TO_FLAGS,
|
||||
)
|
||||
from ezdxf.lldxf import const
|
||||
from ezdxf.math import UVec, Vec3
|
||||
from .mixins import SubscriptAttributes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.eztypes import GenericLayoutType
|
||||
|
||||
TOP_ALIGN = {
|
||||
MTextEntityAlignment.TOP_LEFT,
|
||||
MTextEntityAlignment.TOP_RIGHT,
|
||||
MTextEntityAlignment.TOP_CENTER,
|
||||
}
|
||||
MIDDLE_ALIGN = {
|
||||
MTextEntityAlignment.MIDDLE_LEFT,
|
||||
MTextEntityAlignment.MIDDLE_CENTER,
|
||||
MTextEntityAlignment.MIDDLE_RIGHT,
|
||||
}
|
||||
|
||||
|
||||
class Mirror(enum.IntEnum):
|
||||
NONE = 0
|
||||
MIRROR_X = 2
|
||||
MIRROR_Y = 4
|
||||
|
||||
|
||||
class MTextSurrogate(SubscriptAttributes):
|
||||
"""MTEXT surrogate for DXF R12 build up by TEXT Entities. This add-on was
|
||||
added to simplify the transition from :mod:`dxfwrite` to :mod:`ezdxf`.
|
||||
|
||||
The rich-text formatting capabilities for the regular MTEXT entity are not
|
||||
supported, if these features are required use the regular MTEXT entity and
|
||||
the :class:`~ezdxf.addons.MTextExplode` add-on to explode the MTEXT entity
|
||||
into DXF primitives.
|
||||
|
||||
.. important::
|
||||
|
||||
The align-point is always the insert-point, there is no need for
|
||||
a second align-point because the horizontal alignments FIT, ALIGN,
|
||||
BASELINE_MIDDLE are not supported.
|
||||
|
||||
Args:
|
||||
text: content as string
|
||||
insert: insert location in drawing units
|
||||
line_spacing: line spacing in percent of height, 1.5 = 150% = 1+1/2 lines
|
||||
align: text alignment as :class:`~ezdxf.enums.MTextEntityAlignment` enum
|
||||
char_height: text height in drawing units
|
||||
style: :class:`~ezdxf.entities.Textstyle` name as string
|
||||
oblique: oblique angle in degrees, where 0 is vertical
|
||||
rotation: text rotation angle in degrees
|
||||
width_factor: text width factor as float
|
||||
mirror: :attr:`MTextSurrogate.MIRROR_X` to mirror the text horizontal
|
||||
or :attr:`MTextSurrogate.MIRROR_Y` to mirror the text vertical
|
||||
layer: layer name as string
|
||||
color: :ref:`ACI`
|
||||
|
||||
"""
|
||||
|
||||
MIRROR_NONE = Mirror.NONE
|
||||
MIRROR_X = Mirror.MIRROR_X
|
||||
MIRROR_Y = Mirror.MIRROR_Y
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
insert: UVec,
|
||||
line_spacing: float = 1.5,
|
||||
align=MTextEntityAlignment.TOP_LEFT,
|
||||
char_height: float = 1.0,
|
||||
style="STANDARD",
|
||||
oblique: float = 0.0,
|
||||
rotation: float = 0.0,
|
||||
width_factor: float = 1.0,
|
||||
mirror=Mirror.NONE,
|
||||
layer="0",
|
||||
color: int = const.BYLAYER,
|
||||
):
|
||||
self.content: list[str] = text.split("\n")
|
||||
self.insert = Vec3(insert)
|
||||
self.line_spacing = float(line_spacing)
|
||||
assert isinstance(align, MTextEntityAlignment)
|
||||
self.align = align
|
||||
|
||||
self.char_height = float(char_height)
|
||||
self.style = str(style)
|
||||
self.oblique = float(oblique) # in degree
|
||||
self.rotation = float(rotation) # in degree
|
||||
self.width_factor = float(width_factor)
|
||||
self.mirror = int(mirror) # renamed to text_generation_flag in ezdxf
|
||||
self.layer = str(layer)
|
||||
self.color = int(color)
|
||||
|
||||
@property
|
||||
def line_height(self) -> float:
|
||||
"""Absolute line spacing in drawing units."""
|
||||
return self.char_height * self.line_spacing
|
||||
|
||||
def render(self, layout: GenericLayoutType) -> None:
|
||||
"""Render the multi-line content as separated TEXT entities into the
|
||||
given `layout` instance.
|
||||
"""
|
||||
text_lines = self.content
|
||||
if len(text_lines) > 1:
|
||||
if self.mirror & const.MIRROR_Y:
|
||||
text_lines.reverse()
|
||||
for line_number, text in enumerate(text_lines):
|
||||
align_point = self._get_align_point(line_number)
|
||||
layout.add_text(
|
||||
text,
|
||||
dxfattribs=self._dxfattribs(align_point),
|
||||
)
|
||||
elif len(text_lines) == 1:
|
||||
layout.add_text(
|
||||
text_lines[0],
|
||||
dxfattribs=self._dxfattribs(self.insert),
|
||||
)
|
||||
|
||||
def _get_align_point(self, line_number: int) -> Vec3:
|
||||
"""Calculate the align-point depending on the line number."""
|
||||
x = self.insert.x
|
||||
y = self.insert.y
|
||||
try:
|
||||
z = self.insert.z
|
||||
except IndexError:
|
||||
z = 0.0
|
||||
# rotation not respected
|
||||
|
||||
if self.align in TOP_ALIGN:
|
||||
y -= line_number * self.line_height
|
||||
elif self.align in MIDDLE_ALIGN:
|
||||
y0 = line_number * self.line_height
|
||||
full_height = (len(self.content) - 1) * self.line_height
|
||||
y += (full_height / 2) - y0
|
||||
else: # BOTTOM ALIGN
|
||||
y += (len(self.content) - 1 - line_number) * self.line_height
|
||||
return self._rotate(Vec3(x, y, z))
|
||||
|
||||
def _rotate(self, alignpoint: Vec3) -> Vec3:
|
||||
"""Rotate `alignpoint` around insert-point about rotation degrees."""
|
||||
dx = alignpoint.x - self.insert.x
|
||||
dy = alignpoint.y - self.insert.y
|
||||
beta = math.radians(self.rotation)
|
||||
x = self.insert.x + dx * math.cos(beta) - dy * math.sin(beta)
|
||||
y = self.insert.y + dy * math.cos(beta) + dx * math.sin(beta)
|
||||
return Vec3(round(x, 6), round(y, 6), alignpoint.z)
|
||||
|
||||
def _dxfattribs(self, align_point: Vec3) -> dict[str, Any]:
|
||||
"""Build keyword arguments for TEXT entity creation."""
|
||||
halign, valign = MAP_MTEXT_ALIGN_TO_FLAGS[self.align]
|
||||
return {
|
||||
"insert": align_point,
|
||||
"align_point": align_point,
|
||||
"layer": self.layer,
|
||||
"color": self.color,
|
||||
"style": self.style,
|
||||
"height": self.char_height,
|
||||
"width": self.width_factor,
|
||||
"text_generation_flag": self.mirror,
|
||||
"rotation": self.rotation,
|
||||
"oblique": self.oblique,
|
||||
"halign": halign,
|
||||
"valign": valign,
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import cast, Any, Optional, TYPE_CHECKING
|
||||
import math
|
||||
import ezdxf
|
||||
from ezdxf.entities import MText, DXFGraphic, Textstyle
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
|
||||
# from ezdxf.layouts import BaseLayout
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.math import Matrix44
|
||||
from ezdxf.fonts import fonts
|
||||
from ezdxf.tools import text_layout as tl
|
||||
from ezdxf.tools.text import MTextContext
|
||||
from ezdxf.render.abstract_mtext_renderer import AbstractMTextRenderer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.eztypes import GenericLayoutType
|
||||
|
||||
__all__ = ["MTextExplode"]
|
||||
|
||||
|
||||
class FrameRenderer(tl.ContentRenderer):
|
||||
def __init__(self, attribs: dict, layout: GenericLayoutType):
|
||||
self.line_attribs = attribs
|
||||
self.layout = layout
|
||||
|
||||
def render(
|
||||
self,
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Matrix44 = None,
|
||||
) -> None:
|
||||
pline = self.layout.add_lwpolyline(
|
||||
[(left, top), (right, top), (right, bottom), (left, bottom)],
|
||||
close=True,
|
||||
dxfattribs=self.line_attribs,
|
||||
)
|
||||
if m:
|
||||
pline.transform(m)
|
||||
|
||||
def line(
|
||||
self, x1: float, y1: float, x2: float, y2: float, m: Matrix44 = None
|
||||
) -> None:
|
||||
line = self.layout.add_line((x1, y1), (x2, y2), dxfattribs=self.line_attribs)
|
||||
if m:
|
||||
line.transform(m)
|
||||
|
||||
|
||||
class ColumnBackgroundRenderer(FrameRenderer):
|
||||
def __init__(
|
||||
self,
|
||||
attribs: dict,
|
||||
layout: GenericLayoutType,
|
||||
bg_aci: Optional[int] = None,
|
||||
bg_true_color: Optional[int] = None,
|
||||
offset: float = 0,
|
||||
text_frame: bool = False,
|
||||
):
|
||||
super().__init__(attribs, layout)
|
||||
self.solid_attribs = None
|
||||
if bg_aci is not None:
|
||||
self.solid_attribs = dict(attribs)
|
||||
self.solid_attribs["color"] = bg_aci
|
||||
elif bg_true_color is not None:
|
||||
self.solid_attribs = dict(attribs)
|
||||
self.solid_attribs["true_color"] = bg_true_color
|
||||
self.offset = offset # background border offset
|
||||
self.has_text_frame = text_frame
|
||||
|
||||
def render(
|
||||
self,
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Optional[Matrix44] = None,
|
||||
) -> None:
|
||||
# Important: this is not a clipping box, it is possible to
|
||||
# render anything outside of the given borders!
|
||||
offset = self.offset
|
||||
left -= offset
|
||||
right += offset
|
||||
top += offset
|
||||
bottom -= offset
|
||||
if self.solid_attribs is not None:
|
||||
solid = self.layout.add_solid(
|
||||
# SOLID! swap last two vertices:
|
||||
[(left, top), (right, top), (left, bottom), (right, bottom)],
|
||||
dxfattribs=self.solid_attribs,
|
||||
)
|
||||
if m:
|
||||
solid.transform(m)
|
||||
if self.has_text_frame:
|
||||
super().render(left, bottom, right, top, m)
|
||||
|
||||
|
||||
class TextRenderer(FrameRenderer):
|
||||
"""Text content renderer."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
text_attribs: dict,
|
||||
line_attribs: dict,
|
||||
layout: GenericLayoutType,
|
||||
):
|
||||
super().__init__(line_attribs, layout)
|
||||
self.text = text
|
||||
self.text_attribs = text_attribs
|
||||
|
||||
def render(
|
||||
self,
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Optional[Matrix44] = None,
|
||||
):
|
||||
"""Create/render the text content"""
|
||||
text = self.layout.add_text(self.text, dxfattribs=self.text_attribs)
|
||||
text.set_placement((left, bottom), align=TextEntityAlignment.LEFT)
|
||||
if m:
|
||||
text.transform(m)
|
||||
|
||||
|
||||
# todo: replace by fonts.get_entity_font_face()
|
||||
def get_font_face(entity: DXFGraphic, doc=None) -> fonts.FontFace:
|
||||
"""Returns the :class:`~ezdxf.tools.fonts.FontFace` defined by the
|
||||
associated text style. Returns the default font face if the `entity` does
|
||||
not have or support the DXF attribute "style".
|
||||
|
||||
Pass a DXF document as argument `doc` to resolve text styles for virtual
|
||||
entities which are not assigned to a DXF document. The argument `doc`
|
||||
always overrides the DXF document to which the `entity` is assigned to.
|
||||
|
||||
"""
|
||||
if entity.doc and doc is None:
|
||||
doc = entity.doc
|
||||
assert doc is not None, "valid DXF document required"
|
||||
|
||||
style_name = ""
|
||||
# This works also for entities which do not support "style",
|
||||
# where style_name = entity.dxf.get("style") would fail.
|
||||
if entity.dxf.is_supported("style"):
|
||||
style_name = entity.dxf.style
|
||||
|
||||
font_face = fonts.FontFace()
|
||||
if style_name and doc is not None:
|
||||
style = cast(Textstyle, doc.styles.get(style_name))
|
||||
family, italic, bold = style.get_extended_font_data()
|
||||
if family:
|
||||
text_style = "Italic" if italic else "Regular"
|
||||
text_weight = 700 if bold else 400
|
||||
font_face = fonts.FontFace(
|
||||
family=family, style=text_style, weight=text_weight
|
||||
)
|
||||
else:
|
||||
ttf = style.dxf.font
|
||||
if ttf:
|
||||
font_face = fonts.get_font_face(ttf)
|
||||
return font_face
|
||||
|
||||
|
||||
def get_color_attribs(ctx: MTextContext) -> dict:
|
||||
attribs = {"color": ctx.aci}
|
||||
if ctx.rgb is not None:
|
||||
attribs["true_color"] = ezdxf.rgb2int(ctx.rgb)
|
||||
return attribs
|
||||
|
||||
|
||||
def make_bg_renderer(mtext: MText, layout: GenericLayoutType):
|
||||
attribs = get_base_attribs(mtext)
|
||||
dxf = mtext.dxf
|
||||
bg_fill = dxf.get("bg_fill", 0)
|
||||
|
||||
bg_aci = None
|
||||
bg_true_color = None
|
||||
has_text_frame = False
|
||||
offset = 0
|
||||
if bg_fill:
|
||||
# The fill scale is a multiple of the initial char height and
|
||||
# a scale of 1, fits exact the outer border
|
||||
# of the column -> offset = 0
|
||||
offset = dxf.char_height * (dxf.get("box_fill_scale", 1.5) - 1)
|
||||
if bg_fill & ezdxf.const.MTEXT_BG_COLOR:
|
||||
if dxf.hasattr("bg_fill_color"):
|
||||
bg_aci = dxf.bg_fill_color
|
||||
|
||||
if dxf.hasattr("bg_fill_true_color"):
|
||||
bg_aci = None
|
||||
bg_true_color = dxf.bg_fill_true_color
|
||||
|
||||
if (bg_fill & 3) == 3: # canvas color = bit 0 and 1 set
|
||||
# can not detect canvas color from DXF document!
|
||||
# do not draw any background:
|
||||
bg_aci = None
|
||||
bg_true_color = None
|
||||
|
||||
if bg_fill & ezdxf.const.MTEXT_TEXT_FRAME:
|
||||
has_text_frame = True
|
||||
|
||||
return ColumnBackgroundRenderer(
|
||||
attribs,
|
||||
layout,
|
||||
bg_aci=bg_aci,
|
||||
bg_true_color=bg_true_color,
|
||||
offset=offset,
|
||||
text_frame=has_text_frame,
|
||||
)
|
||||
|
||||
|
||||
def get_base_attribs(mtext: MText) -> dict:
|
||||
dxf = mtext.dxf
|
||||
attribs = {
|
||||
"layer": dxf.layer,
|
||||
"color": dxf.color,
|
||||
}
|
||||
return attribs
|
||||
|
||||
|
||||
class MTextExplode(AbstractMTextRenderer):
|
||||
"""The :class:`MTextExplode` class is a tool to disassemble MTEXT entities
|
||||
into single line TEXT entities and additional LINE entities if required to
|
||||
emulate strokes.
|
||||
|
||||
The `layout` argument defines the target layout for "exploded" parts of the
|
||||
MTEXT entity. Use argument `doc` if the target layout has no DXF document assigned
|
||||
like virtual layouts. The `spacing_factor` argument is an advanced tuning parameter
|
||||
to scale the size of space chars.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
layout: GenericLayoutType,
|
||||
doc: Optional[Drawing] = None,
|
||||
spacing_factor: float = 1.0,
|
||||
):
|
||||
super().__init__()
|
||||
self.layout: GenericLayoutType = layout
|
||||
self._doc = doc
|
||||
# scale the width of spaces by this factor:
|
||||
self._spacing_factor = float(spacing_factor)
|
||||
self._required_text_styles: dict[str, fonts.FontFace] = {}
|
||||
self.current_base_attribs: dict[str, Any] = dict()
|
||||
|
||||
# Implementation of required AbstractMTextRenderer methods and overrides:
|
||||
|
||||
def layout_engine(self, mtext: MText) -> tl.Layout:
|
||||
self.current_base_attribs = get_base_attribs(mtext)
|
||||
return super().layout_engine(mtext)
|
||||
|
||||
def word(self, text: str, ctx: MTextContext) -> tl.ContentCell:
|
||||
line_attribs = dict(self.current_base_attribs or {})
|
||||
line_attribs.update(get_color_attribs(ctx))
|
||||
text_attribs = dict(line_attribs)
|
||||
text_attribs.update(self.get_text_attribs(ctx))
|
||||
return tl.Text(
|
||||
width=self.get_font(ctx).text_width(text),
|
||||
height=ctx.cap_height,
|
||||
valign=tl.CellAlignment(ctx.align),
|
||||
stroke=self.get_stroke(ctx),
|
||||
renderer=TextRenderer(text, text_attribs, line_attribs, self.layout),
|
||||
)
|
||||
|
||||
def fraction(self, data: tuple, ctx: MTextContext) -> tl.ContentCell:
|
||||
upr, lwr, type_ = data
|
||||
if type_:
|
||||
return tl.Fraction(
|
||||
top=self.word(upr, ctx),
|
||||
bottom=self.word(lwr, ctx),
|
||||
stacking=self.get_stacking(type_),
|
||||
# renders just the divider line:
|
||||
renderer=FrameRenderer(self.current_base_attribs, self.layout),
|
||||
)
|
||||
else:
|
||||
return self.word(upr, ctx)
|
||||
|
||||
def get_font_face(self, mtext: MText) -> fonts.FontFace:
|
||||
return get_font_face(mtext)
|
||||
|
||||
def make_bg_renderer(self, mtext: MText) -> tl.ContentRenderer:
|
||||
return make_bg_renderer(mtext, self.layout)
|
||||
|
||||
# Implementation details of MTextExplode:
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.finalize()
|
||||
|
||||
def mtext_exploded_text_style(self, font_face: fonts.FontFace) -> str:
|
||||
style = 0
|
||||
if font_face.is_bold:
|
||||
style += 1
|
||||
if font_face.is_italic:
|
||||
style += 2
|
||||
style_str = str(style) if style > 0 else ""
|
||||
# BricsCAD naming convention for exploded MTEXT styles:
|
||||
text_style = f"MtXpl_{font_face.family}" + style_str
|
||||
self._required_text_styles[text_style] = font_face
|
||||
return text_style
|
||||
|
||||
def get_font(self, ctx: MTextContext) -> fonts.AbstractFont:
|
||||
ttf = fonts.find_font_file_name(ctx.font_face)
|
||||
key = (ttf, ctx.cap_height, ctx.width_factor)
|
||||
font = self._font_cache.get(key)
|
||||
if font is None:
|
||||
font = fonts.make_font(ttf, ctx.cap_height, ctx.width_factor)
|
||||
self._font_cache[key] = font
|
||||
return font
|
||||
|
||||
def get_text_attribs(self, ctx: MTextContext) -> dict:
|
||||
attribs = {
|
||||
"height": ctx.cap_height,
|
||||
"style": self.mtext_exploded_text_style(ctx.font_face),
|
||||
}
|
||||
if not math.isclose(ctx.width_factor, 1.0):
|
||||
attribs["width"] = ctx.width_factor
|
||||
if abs(ctx.oblique) > 1e-6:
|
||||
attribs["oblique"] = ctx.oblique
|
||||
return attribs
|
||||
|
||||
def explode(self, mtext: MText, destroy=True):
|
||||
"""Explode `mtext` and destroy the source entity if argument `destroy`
|
||||
is ``True``.
|
||||
"""
|
||||
align = tl.LayoutAlignment(mtext.dxf.attachment_point)
|
||||
layout_engine = self.layout_engine(mtext)
|
||||
layout_engine.place(align=align)
|
||||
layout_engine.render(mtext.ucs().matrix)
|
||||
if destroy:
|
||||
mtext.destroy()
|
||||
|
||||
def finalize(self):
|
||||
"""Create required text styles. This method is called automatically if
|
||||
the class is used as context manager. This method does not work with virtual
|
||||
layouts if no document was assigned at initialization!
|
||||
"""
|
||||
|
||||
doc = self._doc
|
||||
if doc is None:
|
||||
doc = self.layout.doc
|
||||
if doc is None:
|
||||
raise ezdxf.DXFValueError(
|
||||
"DXF document required, finalize() does not work with virtual layouts "
|
||||
"if no document was assigned at initialization."
|
||||
)
|
||||
text_styles = doc.styles
|
||||
for style in self.make_required_style_table_entries():
|
||||
try:
|
||||
text_styles.add_entry(style)
|
||||
except ezdxf.DXFTableEntryError:
|
||||
pass
|
||||
|
||||
def make_required_style_table_entries(self) -> list[Textstyle]:
|
||||
def ttf_path(font_face: fonts.FontFace) -> str:
|
||||
ttf = font_face.filename
|
||||
if not ttf:
|
||||
ttf = fonts.find_font_file_name(font_face)
|
||||
else:
|
||||
# remapping SHX replacement fonts to SHX fonts,
|
||||
# like "txt_____.ttf" to "TXT.SHX":
|
||||
shx = fonts.map_ttf_to_shx(ttf)
|
||||
if shx:
|
||||
ttf = shx
|
||||
return ttf
|
||||
|
||||
text_styles: list[Textstyle] = []
|
||||
for name, font_face in self._required_text_styles.items():
|
||||
ttf = ttf_path(font_face)
|
||||
style = Textstyle.new(dxfattribs={
|
||||
"name": name,
|
||||
"font": ttf,
|
||||
})
|
||||
if not ttf.endswith(".SHX"):
|
||||
style.set_extended_font_data(
|
||||
font_face.family,
|
||||
italic=font_face.is_italic,
|
||||
bold=font_face.is_bold,
|
||||
)
|
||||
text_styles.append(style)
|
||||
return text_styles
|
||||
@@ -0,0 +1,518 @@
|
||||
# Copyright (c) 2020-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.lldxf.validator import (
|
||||
is_dxf_file,
|
||||
dxf_info,
|
||||
is_binary_dxf_file,
|
||||
dwg_version,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ezdxf")
|
||||
|
||||
|
||||
class ODAFCError(IOError):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownODAFCError(ODAFCError):
|
||||
pass
|
||||
|
||||
|
||||
class ODAFCNotInstalledError(ODAFCError):
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedFileFormat(ODAFCError):
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedPlatform(ODAFCError):
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedVersion(ODAFCError):
|
||||
pass
|
||||
|
||||
|
||||
VERSION_MAP = {
|
||||
"R12": "ACAD12",
|
||||
"R13": "ACAD13",
|
||||
"R14": "ACAD14",
|
||||
"R2000": "ACAD2000",
|
||||
"R2004": "ACAD2004",
|
||||
"R2007": "ACAD2007",
|
||||
"R2010": "ACAD2010",
|
||||
"R2013": "ACAD2013",
|
||||
"R2018": "ACAD2018",
|
||||
"AC1004": "ACAD9",
|
||||
"AC1006": "ACAD10",
|
||||
"AC1009": "ACAD12",
|
||||
"AC1012": "ACAD13",
|
||||
"AC1014": "ACAD14",
|
||||
"AC1015": "ACAD2000",
|
||||
"AC1018": "ACAD2004",
|
||||
"AC1021": "ACAD2007",
|
||||
"AC1024": "ACAD2010",
|
||||
"AC1027": "ACAD2013",
|
||||
"AC1032": "ACAD2018",
|
||||
}
|
||||
|
||||
VALID_VERSIONS = {
|
||||
"ACAD9",
|
||||
"ACAD10",
|
||||
"ACAD12",
|
||||
"ACAD13",
|
||||
"ACAD14",
|
||||
"ACAD2000",
|
||||
"ACAD2004",
|
||||
"ACAD2007",
|
||||
"ACAD2010",
|
||||
"ACAD2013",
|
||||
"ACAD2018",
|
||||
}
|
||||
|
||||
WINDOWS = "Windows"
|
||||
LINUX = "Linux"
|
||||
DARWIN = "Darwin"
|
||||
|
||||
|
||||
def get_win_exec_path() -> str:
|
||||
return ezdxf.options.get("odafc-addon", "win_exec_path").strip('"')
|
||||
|
||||
|
||||
def get_unix_exec_path() -> str:
|
||||
return ezdxf.options.get("odafc-addon", "unix_exec_path").strip('"')
|
||||
|
||||
|
||||
def is_installed() -> bool:
|
||||
"""Returns ``True`` if the ODAFileConverter is installed."""
|
||||
if platform.system() in (LINUX, DARWIN):
|
||||
unix_exec_path = get_unix_exec_path()
|
||||
if unix_exec_path and Path(unix_exec_path).is_file():
|
||||
return True
|
||||
return shutil.which("ODAFileConverter") is not None
|
||||
# Windows:
|
||||
return os.path.exists(get_win_exec_path())
|
||||
|
||||
|
||||
def map_version(version: str) -> str:
|
||||
return VERSION_MAP.get(version.upper(), version.upper())
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | os.PathLike, version: Optional[str] = None, *, audit: bool = False
|
||||
) -> Drawing:
|
||||
"""Uses an installed `ODA File Converter`_ to convert a DWG/DXB/DXF file
|
||||
into a temporary DXF file and load this file by `ezdxf`.
|
||||
|
||||
Args:
|
||||
filename: file to load by ODA File Converter
|
||||
version: load file as specific DXF version, by default the same version
|
||||
as the source file or if not detectable the latest by `ezdxf`
|
||||
supported version.
|
||||
audit: audit source file before loading
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: source file not found
|
||||
odafc.UnknownODAFCError: conversion failed for unknown reasons
|
||||
odafc.UnsupportedVersion: invalid DWG version specified
|
||||
odafc.UnsupportedFileFormat: unsupported file extension
|
||||
odafc.ODAFCNotInstalledError: ODA File Converter not installed
|
||||
|
||||
"""
|
||||
infile = Path(filename).absolute()
|
||||
if not infile.is_file():
|
||||
raise FileNotFoundError(f"No such file: '{infile}'")
|
||||
if isinstance(version, str):
|
||||
version = map_version(version)
|
||||
else:
|
||||
version = _detect_version(filename)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir:
|
||||
args = _odafc_arguments(
|
||||
infile.name,
|
||||
str(infile.parent),
|
||||
tmp_dir,
|
||||
output_format="DXF",
|
||||
version=version,
|
||||
audit=audit,
|
||||
)
|
||||
_execute_odafc(args)
|
||||
out_file = Path(tmp_dir) / infile.with_suffix(".dxf").name
|
||||
if out_file.exists():
|
||||
doc = ezdxf.readfile(str(out_file))
|
||||
doc.filename = str(infile.with_suffix(".dxf"))
|
||||
return doc
|
||||
raise UnknownODAFCError("Failed to convert file: Unknown Error")
|
||||
|
||||
|
||||
def export_dwg(
|
||||
doc: Drawing,
|
||||
filename: str | os.PathLike,
|
||||
version: Optional[str] = None,
|
||||
*,
|
||||
audit: bool = False,
|
||||
replace: bool = False,
|
||||
) -> None:
|
||||
"""Uses an installed `ODA File Converter`_ to export the DXF document `doc`
|
||||
as a DWG file.
|
||||
|
||||
A temporary DXF file will be created and converted to DWG by the
|
||||
ODA File Converter. If `version` is not specified the DXF version of the
|
||||
source document is used.
|
||||
|
||||
Args:
|
||||
doc: `ezdxf` DXF document as :class:`~ezdxf.drawing.Drawing` object
|
||||
filename: output DWG filename, the extension will be set to ".dwg"
|
||||
version: DWG version to export, by default the same version as
|
||||
the source document.
|
||||
audit: audit source file by ODA File Converter at exporting
|
||||
replace: replace existing DWG file if ``True``
|
||||
|
||||
Raises:
|
||||
FileExistsError: target file already exists, and argument `replace` is
|
||||
``False``
|
||||
FileNotFoundError: parent directory of target file does not exist
|
||||
odafc.UnknownODAFCError: exporting DWG failed for unknown reasons
|
||||
odafc.ODAFCNotInstalledError: ODA File Converter not installed
|
||||
|
||||
"""
|
||||
if version is None:
|
||||
version = doc.dxfversion
|
||||
export_version = VERSION_MAP[version]
|
||||
dwg_file = Path(filename).absolute()
|
||||
out_folder = Path(dwg_file.parent)
|
||||
if dwg_file.exists():
|
||||
if replace:
|
||||
dwg_file.unlink()
|
||||
else:
|
||||
raise FileExistsError(f"File already exists: {dwg_file}")
|
||||
if out_folder.exists():
|
||||
with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir:
|
||||
dxf_file = Path(tmp_dir) / dwg_file.with_suffix(".dxf").name
|
||||
|
||||
# Save DXF document
|
||||
old_filename = doc.filename
|
||||
doc.saveas(dxf_file)
|
||||
doc.filename = old_filename
|
||||
|
||||
arguments = _odafc_arguments(
|
||||
dxf_file.name,
|
||||
tmp_dir,
|
||||
str(out_folder),
|
||||
output_format="DWG",
|
||||
version=export_version,
|
||||
audit=audit,
|
||||
)
|
||||
_execute_odafc(arguments)
|
||||
else:
|
||||
raise FileNotFoundError(f"No such file or directory: '{str(out_folder)}'")
|
||||
|
||||
|
||||
def convert(
|
||||
source: str | os.PathLike,
|
||||
dest: str | os.PathLike = "",
|
||||
*,
|
||||
version="R2018",
|
||||
audit=True,
|
||||
replace=False,
|
||||
):
|
||||
"""Convert `source` file to `dest` file.
|
||||
|
||||
The file extension defines the target format
|
||||
e.g. :code:`convert("test.dxf", "Test.dwg")` converts the source file to a
|
||||
DWG file.
|
||||
If `dest` is an empty string the conversion depends on the source file
|
||||
format and is DXF to DWG or DWG to DXF.
|
||||
To convert DXF to DXF an explicit destination filename is required:
|
||||
:code:`convert("r12.dxf", "r2013.dxf", version="R2013")`
|
||||
|
||||
Args:
|
||||
source: source file
|
||||
dest: destination file, an empty string uses the source filename with
|
||||
the extension of the target format e.g. "test.dxf" -> "test.dwg"
|
||||
version: output DXF/DWG version e.g. "ACAD2018", "R2018", "AC1032"
|
||||
audit: audit files
|
||||
replace: replace existing destination file
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: source file or destination folder does not exist
|
||||
FileExistsError: destination file already exists and argument `replace`
|
||||
is ``False``
|
||||
odafc.UnsupportedVersion: invalid DXF version specified
|
||||
odafc.UnsupportedFileFormat: unsupported file extension
|
||||
odafc.UnknownODAFCError: conversion failed for unknown reasons
|
||||
odafc.ODAFCNotInstalledError: ODA File Converter not installed
|
||||
|
||||
"""
|
||||
version = map_version(version)
|
||||
if version not in VALID_VERSIONS:
|
||||
raise UnsupportedVersion(f"Invalid version: '{version}'")
|
||||
src_path = Path(source).expanduser().absolute()
|
||||
if not src_path.exists():
|
||||
raise FileNotFoundError(f"Source file not found: '{source}'")
|
||||
if dest:
|
||||
dest_path = Path(dest)
|
||||
else:
|
||||
ext = src_path.suffix.lower()
|
||||
if ext == ".dwg":
|
||||
dest_path = src_path.with_suffix(".dxf")
|
||||
elif ext == ".dxf":
|
||||
dest_path = src_path.with_suffix(".dwg")
|
||||
else:
|
||||
raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'")
|
||||
if dest_path.exists() and not replace:
|
||||
raise FileExistsError(f"Target file already exists: '{dest_path}'")
|
||||
parent_dir = dest_path.parent
|
||||
if not parent_dir.exists() or not parent_dir.is_dir():
|
||||
# Cannot copy result to destination folder!
|
||||
raise FileNotFoundError(f"Destination folder does not exist: '{parent_dir}'")
|
||||
ext = dest_path.suffix
|
||||
fmt = ext.upper()[1:]
|
||||
if fmt not in ("DXF", "DWG"):
|
||||
raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir:
|
||||
arguments = _odafc_arguments(
|
||||
src_path.name,
|
||||
in_folder=str(src_path.parent),
|
||||
out_folder=str(tmp_dir),
|
||||
output_format=fmt,
|
||||
version=version,
|
||||
audit=audit,
|
||||
)
|
||||
_execute_odafc(arguments)
|
||||
result = list(Path(tmp_dir).iterdir())
|
||||
if result:
|
||||
try:
|
||||
shutil.move(result[0], dest_path)
|
||||
except IOError:
|
||||
shutil.copy(result[0], dest_path)
|
||||
else:
|
||||
UnknownODAFCError(f"Unknown error: no {fmt} file was created")
|
||||
|
||||
|
||||
def _detect_version(path: str) -> str:
|
||||
"""Returns the DXF/DWG version of file `path` as ODAFC compatible version
|
||||
string.
|
||||
|
||||
Raises:
|
||||
odafc.UnsupportedVersion: unknown or unsupported DWG version
|
||||
odafc.UnsupportedFileFormat; unsupported file extension
|
||||
|
||||
"""
|
||||
version = "ACAD2018"
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == ".dxf":
|
||||
if is_binary_dxf_file(path):
|
||||
pass
|
||||
elif is_dxf_file(path):
|
||||
with open(path, "rt") as fp:
|
||||
info = dxf_info(fp)
|
||||
version = VERSION_MAP[info.version]
|
||||
elif ext == ".dwg":
|
||||
version = dwg_version(path)
|
||||
if version is None:
|
||||
raise UnsupportedVersion("Unknown or unsupported DWG version.")
|
||||
else:
|
||||
raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'")
|
||||
|
||||
return map_version(version)
|
||||
|
||||
|
||||
def _odafc_arguments(
|
||||
filename: str,
|
||||
in_folder: str,
|
||||
out_folder: str,
|
||||
output_format: str = "DXF",
|
||||
version: str = "ACAD2013",
|
||||
audit: bool = False,
|
||||
) -> list[str]:
|
||||
"""ODA File Converter command line format:
|
||||
|
||||
ODAFileConverter "Input Folder" "Output Folder" version type recurse audit [filter]
|
||||
|
||||
- version: output version: "ACAD9" - "ACAD2018"
|
||||
- type: output file type: "DWG", "DXF", "DXB"
|
||||
- recurse: recurse Input Folder: "0" or "1"
|
||||
- audit: audit each file: "0" or "1"
|
||||
- optional Input files filter: default "*.DWG,*.DXF"
|
||||
|
||||
"""
|
||||
recurse = "0"
|
||||
audit_str = "1" if audit else "0"
|
||||
return [
|
||||
in_folder,
|
||||
out_folder,
|
||||
version,
|
||||
output_format,
|
||||
recurse,
|
||||
audit_str,
|
||||
filename,
|
||||
]
|
||||
|
||||
|
||||
def _get_odafc_path(system: str) -> str:
|
||||
"""Get ODAFC application path.
|
||||
|
||||
Raises:
|
||||
odafc.ODAFCNotInstalledError: ODA File Converter not installed
|
||||
|
||||
"""
|
||||
# on Linux and Darwin check if UNIX_EXEC_PATH is set and exist and
|
||||
# return this path as exec path.
|
||||
# This may help if the "which" command can not find the "ODAFileConverter"
|
||||
# command and also adds support for AppImages provided by ODA.
|
||||
unix_exec_path = get_unix_exec_path()
|
||||
|
||||
if system != WINDOWS and unix_exec_path:
|
||||
if Path(unix_exec_path).is_file():
|
||||
return unix_exec_path
|
||||
else:
|
||||
logger.warning(
|
||||
f"command '{unix_exec_path}' not found, using 'ODAFileConverter'"
|
||||
)
|
||||
|
||||
path = shutil.which("ODAFileConverter")
|
||||
if not path and system == WINDOWS:
|
||||
path = get_win_exec_path()
|
||||
if not Path(path).is_file():
|
||||
path = None
|
||||
|
||||
if not path:
|
||||
raise ODAFCNotInstalledError(
|
||||
f"Could not find ODAFileConverter in the path. "
|
||||
f"Install application from https://www.opendesign.com/guestfiles/oda_file_converter"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _linux_dummy_display():
|
||||
"""See xvbfwrapper library for a more feature complete xvfb interface."""
|
||||
if shutil.which("Xvfb"):
|
||||
display = f":{os.getpid()}" # Each process has its own screen id
|
||||
proc = subprocess.Popen(
|
||||
["Xvfb", display, "-screen", "0", "800x600x24"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
time.sleep(0.1)
|
||||
yield display
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
logger.warning(f"Install xvfb to prevent the ODAFileConverter GUI from opening")
|
||||
yield os.environ["DISPLAY"]
|
||||
|
||||
|
||||
def _run_with_no_gui(
|
||||
system: str, command: str, arguments: list[str]
|
||||
) -> subprocess.Popen:
|
||||
"""Execute ODAFC application without launching the GUI.
|
||||
|
||||
Args:
|
||||
system: "Linux", "Windows" or "Darwin"
|
||||
command: application to execute
|
||||
arguments: ODAFC argument list
|
||||
|
||||
Raises:
|
||||
odafc.UnsupportedPlatform: for unsupported platforms
|
||||
odafc.ODAFCNotInstalledError: ODA File Converter not installed
|
||||
|
||||
"""
|
||||
if system == LINUX:
|
||||
with _linux_dummy_display() as display:
|
||||
env = os.environ.copy()
|
||||
env["DISPLAY"] = display
|
||||
proc = subprocess.run(
|
||||
[command] + arguments, text=True, capture_output=True, env=env
|
||||
)
|
||||
|
||||
elif system == DARWIN:
|
||||
# TODO: unknown how to prevent the GUI from appearing on macOS
|
||||
proc = subprocess.run([command] + arguments, text=True, capture_output=True)
|
||||
|
||||
elif system == WINDOWS:
|
||||
# New code from George-Jiang to solve the GUI pop-up problem
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags = (
|
||||
subprocess.CREATE_NEW_CONSOLE | subprocess.STARTF_USESHOWWINDOW
|
||||
)
|
||||
startupinfo.wShowWindow = subprocess.SW_HIDE
|
||||
proc = subprocess.Popen(
|
||||
[command] + arguments,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
startupinfo=startupinfo,
|
||||
)
|
||||
proc.wait()
|
||||
else:
|
||||
# The OpenDesign Alliance only provides executables for Linux, macOS
|
||||
# and Windows:
|
||||
raise UnsupportedPlatform(f"Unsupported platform: {system}")
|
||||
return proc
|
||||
|
||||
|
||||
def _odafc_failed(system: str, returncode: int, stderr: str) -> bool:
|
||||
# changed v0.18.1, see https://github.com/mozman/ezdxf/issues/707
|
||||
stderr = stderr.strip()
|
||||
if system == LINUX:
|
||||
# ODAFileConverter *always* crashes on Linux even if the output was successful
|
||||
return stderr != "" and stderr != "Quit (core dumped)"
|
||||
elif returncode != 0:
|
||||
return True
|
||||
else:
|
||||
return stderr != ""
|
||||
|
||||
|
||||
def _execute_odafc(arguments: list[str]) -> Optional[bytes]:
|
||||
"""Execute ODAFC application.
|
||||
|
||||
Args:
|
||||
arguments: ODAFC argument list
|
||||
|
||||
Raises:
|
||||
odafc.ODAFCNotInstalledError: ODA File Converter not installed
|
||||
odafc.UnknownODAFCError: execution failed for unknown reasons
|
||||
odafc.UnsupportedPlatform: for unsupported platforms
|
||||
|
||||
"""
|
||||
logger.debug(f"Running ODAFileConverter with arguments: {arguments}")
|
||||
system = platform.system()
|
||||
oda_fc = _get_odafc_path(system)
|
||||
proc = _run_with_no_gui(system, oda_fc, arguments)
|
||||
returncode = proc.returncode
|
||||
if system == WINDOWS:
|
||||
stdout = proc.stdout.read().decode("utf-8")
|
||||
stderr = proc.stderr.read().decode("utf-8")
|
||||
else:
|
||||
stdout = proc.stdout
|
||||
stderr = proc.stderr
|
||||
|
||||
if _odafc_failed(system, returncode, stderr):
|
||||
msg = (
|
||||
f"ODA File Converter failed: return code = {returncode}.\n"
|
||||
f"stdout: {stdout}\nstderr: {stderr}"
|
||||
)
|
||||
logger.debug(msg)
|
||||
raise UnknownODAFCError(msg)
|
||||
return stdout
|
||||
@@ -0,0 +1,315 @@
|
||||
# Copyright (c) 2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Union, Iterable, Sequence, Optional
|
||||
from pathlib import Path
|
||||
import enum
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from uuid import uuid4
|
||||
import tempfile
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.math import Matrix44, UVec, Vec3, Vec2
|
||||
from ezdxf.render import MeshBuilder, MeshTransformer
|
||||
from ezdxf.addons import meshex
|
||||
|
||||
|
||||
class Operation(enum.Enum):
|
||||
union = 0
|
||||
difference = 1
|
||||
intersection = 2
|
||||
|
||||
|
||||
CMD = "openscad"
|
||||
UNION = Operation.union
|
||||
DIFFERENCE = Operation.difference
|
||||
INTERSECTION = Operation.intersection
|
||||
|
||||
|
||||
def get_openscad_path() -> str:
|
||||
if platform.system() in ("Linux", "Darwin"):
|
||||
return CMD
|
||||
else:
|
||||
return ezdxf.options.get("openscad-addon", "win_exec_path").strip('"')
|
||||
|
||||
|
||||
def is_installed() -> bool:
|
||||
"""Returns ``True`` if OpenSCAD is installed.
|
||||
|
||||
Searches on Windows the path stored in the options as "win_exec_path" in section
|
||||
"[openscad-addon]" which is "C:\\Program Files\\OpenSCAD\\openscad.exe" by default.
|
||||
|
||||
Searches the "openscad" command on Linux and macOS.
|
||||
|
||||
"""
|
||||
if platform.system() in ("Linux", "Darwin"):
|
||||
return shutil.which(CMD) is not None
|
||||
return Path(get_openscad_path()).exists()
|
||||
|
||||
|
||||
def run(script: str, exec_path: Optional[str] = None) -> MeshTransformer:
|
||||
"""Executes the given `script` by OpenSCAD and returns the result mesh as
|
||||
:class:`~ezdxf.render.MeshTransformer`.
|
||||
|
||||
Args:
|
||||
script: the OpenSCAD script as string
|
||||
exec_path: path to the executable as string or ``None`` to use the
|
||||
default installation path
|
||||
|
||||
"""
|
||||
if exec_path is None:
|
||||
exec_path = get_openscad_path()
|
||||
|
||||
workdir = Path(tempfile.gettempdir())
|
||||
uuid = str(uuid4())
|
||||
# The OFF format is more compact than the default STL format
|
||||
off_path = workdir / f"ezdxf_{uuid}.off"
|
||||
scad_path = workdir / f"ezdxf_{uuid}.scad"
|
||||
|
||||
scad_path.write_text(script)
|
||||
subprocess.call(
|
||||
[
|
||||
exec_path,
|
||||
"--quiet",
|
||||
"-o",
|
||||
str(off_path),
|
||||
str(scad_path),
|
||||
]
|
||||
)
|
||||
# Remove the OpenSCAD temp file:
|
||||
scad_path.unlink(missing_ok=True)
|
||||
|
||||
new_mesh = MeshTransformer()
|
||||
# Import the OpenSCAD result from OFF file:
|
||||
if off_path.exists():
|
||||
new_mesh = meshex.off_loads(off_path.read_text())
|
||||
|
||||
# Remove the OFF temp file:
|
||||
off_path.unlink(missing_ok=True)
|
||||
return new_mesh
|
||||
|
||||
|
||||
def str_matrix44(m: Matrix44) -> str:
|
||||
# OpenSCAD uses column major order!
|
||||
import numpy as np
|
||||
|
||||
def cleanup(values: Iterable) -> Iterable:
|
||||
for value in values:
|
||||
if isinstance(value, np.float64):
|
||||
yield float(value)
|
||||
else:
|
||||
yield value
|
||||
|
||||
s = ", ".join([str(list(cleanup(c))) for c in m.columns()])
|
||||
return f"[{s}]"
|
||||
|
||||
|
||||
def str_polygon(
|
||||
path: Iterable[UVec],
|
||||
holes: Optional[Sequence[Iterable[UVec]]] = None,
|
||||
) -> str:
|
||||
"""Returns a ``polygon()`` command as string. This is a 2D command, all
|
||||
z-axis values of the input vertices are ignored and all paths and holes
|
||||
are closed automatically.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Using_the_2D_Subsystem#polygon
|
||||
|
||||
Args:
|
||||
path: exterior path
|
||||
holes: a sequences of one or more holes as vertices
|
||||
|
||||
"""
|
||||
|
||||
def add_vertices(vertices):
|
||||
index = len(points)
|
||||
indices = []
|
||||
vlist = Vec2.list(vertices)
|
||||
if not vlist[0].isclose(vlist[-1]):
|
||||
vlist.append(vlist[0])
|
||||
|
||||
for v in vlist:
|
||||
indices.append(index)
|
||||
points.append(f" [{v.x:g}, {v.y:g}],")
|
||||
index += 1
|
||||
return indices
|
||||
|
||||
points: list[str] = []
|
||||
paths = [add_vertices(path)]
|
||||
if holes is not None:
|
||||
for hole in holes:
|
||||
paths.append(add_vertices(hole))
|
||||
lines = ["polygon(points = ["]
|
||||
lines.extend(points)
|
||||
lines.append("],")
|
||||
if holes is not None:
|
||||
lines.append("paths = [")
|
||||
for indices in paths:
|
||||
lines.append(f" {str(indices)},")
|
||||
lines.append("],")
|
||||
lines.append("convexity = 10);")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class Script:
|
||||
def __init__(self) -> None:
|
||||
self.data: list[str] = []
|
||||
|
||||
def add(self, data: str) -> None:
|
||||
"""Add a string."""
|
||||
self.data.append(data)
|
||||
|
||||
def add_polyhedron(self, mesh: MeshBuilder) -> None:
|
||||
"""Add `mesh` as ``polyhedron()`` command.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Primitive_Solids#polyhedron
|
||||
|
||||
"""
|
||||
self.add(meshex.scad_dumps(mesh))
|
||||
|
||||
def add_polygon(
|
||||
self,
|
||||
path: Iterable[UVec],
|
||||
holes: Optional[Sequence[Iterable[UVec]]] = None,
|
||||
) -> None:
|
||||
"""Add a ``polygon()`` command. This is a 2D command, all
|
||||
z-axis values of the input vertices are ignored and all paths and holes
|
||||
are closed automatically.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Using_the_2D_Subsystem#polygon
|
||||
|
||||
Args:
|
||||
path: exterior path
|
||||
holes: a sequence of one or more holes as vertices, or ``None`` for no holes
|
||||
|
||||
"""
|
||||
self.add(str_polygon(path, holes))
|
||||
|
||||
def add_multmatrix(self, m: Matrix44) -> None:
|
||||
"""Add a transformation matrix of type :class:`~ezdxf.math.Matrix44` as
|
||||
``multmatrix()`` operation.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#multmatrix
|
||||
|
||||
"""
|
||||
self.add(f"multmatrix(m = {str_matrix44(m)})") # no pending ";"
|
||||
|
||||
def add_translate(self, v: UVec) -> None:
|
||||
"""Add a ``translate()`` operation.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#translate
|
||||
|
||||
Args:
|
||||
v: translation vector
|
||||
|
||||
"""
|
||||
vec = Vec3(v)
|
||||
self.add(f"translate(v = [{vec.x:g}, {vec.y:g}, {vec.z:g}])")
|
||||
|
||||
def add_rotate(self, ax: float, ay: float, az: float) -> None:
|
||||
"""Add a ``rotation()`` operation.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#rotate
|
||||
|
||||
Args:
|
||||
ax: rotation about the x-axis in degrees
|
||||
ay: rotation about the y-axis in degrees
|
||||
az: rotation about the z-axis in degrees
|
||||
|
||||
"""
|
||||
self.add(f"rotate(a = [{ax:g}, {ay:g}, {az:g}])")
|
||||
|
||||
def add_rotate_about_axis(self, a: float, v: UVec) -> None:
|
||||
"""Add a ``rotation()`` operation about the given axis `v`.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#rotate
|
||||
|
||||
Args:
|
||||
a: rotation angle about axis `v` in degrees
|
||||
v: rotation axis as :class:`ezdxf.math.UVec` object
|
||||
|
||||
"""
|
||||
vec = Vec3(v)
|
||||
self.add(f"rotate(a = {a:g}, v = [{vec.x:g}, {vec.y:g}, {vec.z:g}])")
|
||||
|
||||
def add_scale(self, sx: float, sy: float, sz: float) -> None:
|
||||
"""Add a ``scale()`` operation.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#scale
|
||||
|
||||
Args:
|
||||
sx: scaling factor for the x-axis
|
||||
sy: scaling factor for the y-axis
|
||||
sz: scaling factor for the z-axis
|
||||
|
||||
"""
|
||||
self.add(f"scale(v = [{sx:g}, {sy:g}, {sz:g}])")
|
||||
|
||||
def add_resize(
|
||||
self,
|
||||
nx: float,
|
||||
ny: float,
|
||||
nz: float,
|
||||
auto: Optional[Union[bool, tuple[bool, bool, bool]]] = None,
|
||||
) -> None:
|
||||
"""Add a ``resize()`` operation.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#resize
|
||||
|
||||
Args:
|
||||
nx: new size in x-axis
|
||||
ny: new size in y-axis
|
||||
nz: new size in z-axis
|
||||
auto: If the `auto` argument is set to ``True``, the operation
|
||||
auto-scales any 0-dimensions to match. Set the `auto` argument
|
||||
as a 3-tuple of bool values to auto-scale individual axis.
|
||||
|
||||
"""
|
||||
main = f"resize(newsize = [{nx:g}, {ny:g}, {nz:g}]"
|
||||
if auto is None:
|
||||
self.add(main + ")")
|
||||
return
|
||||
elif isinstance(auto, bool):
|
||||
flags = str(auto).lower()
|
||||
else:
|
||||
flags = ", ".join([str(a).lower() for a in auto])
|
||||
flags = f"[{flags}]"
|
||||
self.add(main + f", auto = {flags})")
|
||||
|
||||
def add_mirror(self, v: UVec) -> None:
|
||||
"""Add a ``mirror()`` operation.
|
||||
|
||||
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#mirror
|
||||
|
||||
Args:
|
||||
v: the normal vector of a plane intersecting the origin through
|
||||
which to mirror the object
|
||||
|
||||
"""
|
||||
n = Vec3(v).normalize()
|
||||
self.add(f"mirror(v = [{n.x:g}, {n.y:g}, {n.z:g}])")
|
||||
|
||||
def get_string(self) -> str:
|
||||
"""Returns the OpenSCAD build script."""
|
||||
return "\n".join(self.data)
|
||||
|
||||
|
||||
def boolean_operation(op: Operation, mesh1: MeshBuilder, mesh2: MeshBuilder) -> str:
|
||||
"""Returns an `OpenSCAD`_ script to apply the given boolean operation to the
|
||||
given meshes.
|
||||
|
||||
The supported operations are:
|
||||
|
||||
- UNION
|
||||
- DIFFERENCE
|
||||
- INTERSECTION
|
||||
|
||||
"""
|
||||
assert isinstance(op, Operation), "enum of type Operation expected"
|
||||
script = Script()
|
||||
script.add(f"{op.name}() {{")
|
||||
script.add_polyhedron(mesh1)
|
||||
script.add_polyhedron(mesh2)
|
||||
script.add("}")
|
||||
return script.get_string()
|
||||
@@ -0,0 +1,444 @@
|
||||
# License
|
||||
# Copyright (c) 2011 Evan Wallace (http://madebyevan.com/), under the MIT license.
|
||||
# Python port Copyright (c) 2012 Tim Knip (http://www.floorplanner.com), under the MIT license.
|
||||
# Additions by Alex Pletzer (Pennsylvania State University)
|
||||
# Integration as ezdxf add-on, Copyright (c) 2020, Manfred Moitzi, MIT License.
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from ezdxf.math import Vec3, best_fit_normal, normal_vector_3p
|
||||
from ezdxf.render import MeshVertexMerger, MeshBuilder, MeshTransformer
|
||||
|
||||
# Implementation Details
|
||||
# ----------------------
|
||||
#
|
||||
# All CSG operations are implemented in terms of two functions, clip_to() and
|
||||
# invert(), which remove parts of a BSP tree inside another BSP tree and swap
|
||||
# solid and empty space, respectively. To find the union of a and b, we
|
||||
# want to remove everything in a inside b and everything in b inside a,
|
||||
# then combine polygons from a and b into one solid:
|
||||
#
|
||||
# a.clip_to(b)
|
||||
# b.clip_to(a)
|
||||
# a.build(b.all_polygons())
|
||||
#
|
||||
# The only tricky part is handling overlapping coplanar polygons in both trees.
|
||||
# The code above keeps both copies, but we need to keep them in one tree and
|
||||
# remove them in the other tree. To remove them from b we can clip the
|
||||
# inverse of b against a. The code for union now looks like this:
|
||||
#
|
||||
# a.clip_to(b)
|
||||
# b.clip_to(a)
|
||||
# b.invert()
|
||||
# b.clip_to(a)
|
||||
# b.invert()
|
||||
# a.build(b.all_polygons())
|
||||
#
|
||||
# Subtraction and intersection naturally follow from set operations. If
|
||||
# union is A | B, subtraction is A - B = ~(~A | B) and intersection is
|
||||
# A & B = ~(~A | ~B) where '~' is the complement operator.
|
||||
|
||||
__all__ = ["CSG"]
|
||||
|
||||
COPLANAR = 0 # all the vertices are within EPSILON distance from plane
|
||||
FRONT = 1 # all the vertices are in front of the plane
|
||||
BACK = 2 # all the vertices are at the back of the plane
|
||||
SPANNING = 3 # some vertices are in front, some in the back
|
||||
PLANE_EPSILON = 1e-5 # Tolerance used by split_polygon() to decide if a point is on the plane.
|
||||
|
||||
|
||||
class Plane:
|
||||
"""Represents a plane in 3D space."""
|
||||
|
||||
__slots__ = ("normal", "w")
|
||||
|
||||
def __init__(self, normal: Vec3, w: float):
|
||||
self.normal = normal
|
||||
# w is the (perpendicular) distance of the plane from (0, 0, 0)
|
||||
self.w = w
|
||||
|
||||
@classmethod
|
||||
def from_points(cls, a: Vec3, b: Vec3, c: Vec3) -> Plane:
|
||||
n = normal_vector_3p(a, b, c)
|
||||
return Plane(n, n.dot(a))
|
||||
|
||||
def clone(self) -> Plane:
|
||||
return Plane(self.normal, self.w)
|
||||
|
||||
def flip(self) -> None:
|
||||
self.normal = -self.normal
|
||||
self.w = -self.w
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Plane({self.normal}, {self.w})"
|
||||
|
||||
def split_polygon(
|
||||
self,
|
||||
polygon: "Polygon",
|
||||
coplanar_front: list[Polygon],
|
||||
coplanar_back: list[Polygon],
|
||||
front: list[Polygon],
|
||||
back: list[Polygon],
|
||||
) -> None:
|
||||
"""
|
||||
Split `polygon` by this plane if needed, then put the polygon or polygon
|
||||
fragments in the appropriate lists. Coplanar polygons go into either
|
||||
`coplanarFront` or `coplanarBack` depending on their orientation with
|
||||
respect to this plane. Polygons in front or in back of this plane go into
|
||||
either `front` or `back`
|
||||
"""
|
||||
polygon_type = 0
|
||||
vertex_types = []
|
||||
vertices = polygon.vertices
|
||||
meshid = polygon.meshid # mesh ID of the associated mesh
|
||||
|
||||
# Classify each point as well as the entire polygon into one of four classes:
|
||||
# COPLANAR, FRONT, BACK, SPANNING = FRONT + BACK
|
||||
for vertex in vertices:
|
||||
distance = self.normal.dot(vertex) - self.w
|
||||
if distance < -PLANE_EPSILON:
|
||||
vertex_type = BACK
|
||||
elif distance > PLANE_EPSILON:
|
||||
vertex_type = FRONT
|
||||
else:
|
||||
vertex_type = COPLANAR
|
||||
polygon_type |= vertex_type
|
||||
vertex_types.append(vertex_type)
|
||||
|
||||
# Put the polygon in the correct list, splitting it when necessary.
|
||||
if polygon_type == COPLANAR:
|
||||
if self.normal.dot(polygon.plane.normal) > 0:
|
||||
coplanar_front.append(polygon)
|
||||
else:
|
||||
coplanar_back.append(polygon)
|
||||
elif polygon_type == FRONT:
|
||||
front.append(polygon)
|
||||
elif polygon_type == BACK:
|
||||
back.append(polygon)
|
||||
elif polygon_type == SPANNING:
|
||||
front_vertices = []
|
||||
back_vertices = []
|
||||
len_vertices = len(vertices)
|
||||
for index in range(len_vertices):
|
||||
next_index = (index + 1) % len_vertices
|
||||
vertex_type = vertex_types[index]
|
||||
next_vertex_type = vertex_types[next_index]
|
||||
vertex = vertices[index]
|
||||
next_vertex = vertices[next_index]
|
||||
if vertex_type != BACK: # FRONT or COPLANAR
|
||||
front_vertices.append(vertex)
|
||||
if vertex_type != FRONT: # BACK or COPLANAR
|
||||
back_vertices.append(vertex)
|
||||
if (vertex_type | next_vertex_type) == SPANNING:
|
||||
interpolation_weight = (
|
||||
self.w - self.normal.dot(vertex)
|
||||
) / self.normal.dot(next_vertex - vertex)
|
||||
plane_intersection_point = vertex.lerp(
|
||||
next_vertex, interpolation_weight
|
||||
)
|
||||
front_vertices.append(plane_intersection_point)
|
||||
back_vertices.append(plane_intersection_point)
|
||||
if len(front_vertices) >= 3:
|
||||
front.append(Polygon(front_vertices, meshid=meshid))
|
||||
if len(back_vertices) >= 3:
|
||||
back.append(Polygon(back_vertices, meshid=meshid))
|
||||
|
||||
|
||||
class Polygon:
|
||||
"""
|
||||
Represents a convex polygon. The vertices used to initialize a polygon must
|
||||
be coplanar and form a convex loop, the `meshid` argument associates a polygon
|
||||
to a mesh.
|
||||
|
||||
Args:
|
||||
vertices: polygon vertices as :class:`Vec3` objects
|
||||
meshid: id associated mesh
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("vertices", "plane", "meshid")
|
||||
|
||||
def __init__(self, vertices: list[Vec3], meshid: int = 0):
|
||||
self.vertices = vertices
|
||||
try:
|
||||
normal = normal_vector_3p(vertices[0], vertices[1], vertices[2])
|
||||
except ZeroDivisionError: # got three colinear vertices
|
||||
normal = best_fit_normal(vertices)
|
||||
self.plane = Plane(normal, normal.dot(vertices[0]))
|
||||
# number of mesh, this polygon is associated to
|
||||
self.meshid = meshid
|
||||
|
||||
def clone(self) -> Polygon:
|
||||
return Polygon(list(self.vertices), meshid=self.meshid)
|
||||
|
||||
def flip(self) -> None:
|
||||
self.vertices.reverse()
|
||||
self.plane.flip()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
v = ", ".join(repr(v) for v in self.vertices)
|
||||
return f"Polygon([{v}], mesh={self.meshid})"
|
||||
|
||||
|
||||
class BSPNode:
|
||||
"""
|
||||
Holds a node in a BSP tree. A BSP tree is built from a collection of polygons
|
||||
by picking a polygon to split along. That polygon (and all other coplanar
|
||||
polygons) are added directly to that node and the other polygons are added to
|
||||
the front and/or back subtrees. This is not a leafy BSP tree since there is
|
||||
no distinction between internal and leaf nodes.
|
||||
"""
|
||||
|
||||
__slots__ = ("plane", "front", "back", "polygons")
|
||||
|
||||
def __init__(self, polygons: Optional[list[Polygon]] = None):
|
||||
self.plane: Optional[Plane] = None
|
||||
self.front: Optional[BSPNode] = None
|
||||
self.back: Optional[BSPNode] = None
|
||||
self.polygons: list[Polygon] = []
|
||||
if polygons:
|
||||
self.build(polygons)
|
||||
|
||||
def clone(self) -> BSPNode:
|
||||
node = BSPNode()
|
||||
if self.plane:
|
||||
node.plane = self.plane.clone()
|
||||
if self.front:
|
||||
node.front = self.front.clone()
|
||||
if self.back:
|
||||
node.back = self.back.clone()
|
||||
node.polygons = [p.clone() for p in self.polygons]
|
||||
return node
|
||||
|
||||
def invert(self) -> None:
|
||||
"""Convert solid space to empty space and empty space to solid space."""
|
||||
for poly in self.polygons:
|
||||
poly.flip()
|
||||
assert self.plane is not None
|
||||
self.plane.flip()
|
||||
if self.front:
|
||||
self.front.invert()
|
||||
if self.back:
|
||||
self.back.invert()
|
||||
self.front, self.back = self.back, self.front
|
||||
|
||||
def clip_polygons(self, polygons: list[Polygon]) -> list[Polygon]:
|
||||
"""Recursively remove all polygons in `polygons` that are inside this
|
||||
BSP tree.
|
||||
|
||||
"""
|
||||
if self.plane is None:
|
||||
return polygons[:]
|
||||
|
||||
front: list[Polygon] = []
|
||||
back: list[Polygon] = []
|
||||
for polygon in polygons:
|
||||
self.plane.split_polygon(polygon, front, back, front, back)
|
||||
|
||||
if self.front:
|
||||
front = self.front.clip_polygons(front)
|
||||
|
||||
if self.back:
|
||||
back = self.back.clip_polygons(back)
|
||||
else:
|
||||
back = []
|
||||
|
||||
front.extend(back)
|
||||
return front
|
||||
|
||||
def clip_to(self, bsp: BSPNode) -> None:
|
||||
"""Remove all polygons in this BSP tree that are inside the other BSP
|
||||
tree `bsp`.
|
||||
"""
|
||||
self.polygons = bsp.clip_polygons(self.polygons)
|
||||
if self.front:
|
||||
self.front.clip_to(bsp)
|
||||
if self.back:
|
||||
self.back.clip_to(bsp)
|
||||
|
||||
def all_polygons(self) -> list[Polygon]:
|
||||
"""Return a list of all polygons in this BSP tree."""
|
||||
polygons = self.polygons[:]
|
||||
if self.front:
|
||||
polygons.extend(self.front.all_polygons())
|
||||
if self.back:
|
||||
polygons.extend(self.back.all_polygons())
|
||||
return polygons
|
||||
|
||||
def build(self, polygons: list[Polygon]) -> None:
|
||||
"""
|
||||
Build a BSP tree out of `polygons`. When called on an existing tree, the
|
||||
new polygons are filtered down to the bottom of the tree and become new
|
||||
nodes there. Each set of polygons is partitioned using the first polygon
|
||||
(no heuristic is used to pick a good split).
|
||||
"""
|
||||
if len(polygons) == 0:
|
||||
return
|
||||
if self.plane is None:
|
||||
# do a wise choice and pick the first polygon as split-plane ;)
|
||||
self.plane = polygons[0].plane.clone()
|
||||
# add first polygon to this node
|
||||
self.polygons.append(polygons[0])
|
||||
front: list[Polygon] = []
|
||||
back: list[Polygon] = []
|
||||
# split all other polygons at the split plane
|
||||
for poly in polygons[1:]:
|
||||
# coplanar front and back polygons go into self.polygons
|
||||
self.plane.split_polygon(
|
||||
poly, self.polygons, self.polygons, front, back
|
||||
)
|
||||
# recursively build the BSP tree
|
||||
if len(front) > 0:
|
||||
if self.front is None:
|
||||
self.front = BSPNode()
|
||||
self.front.build(front)
|
||||
if len(back) > 0:
|
||||
if self.back is None:
|
||||
self.back = BSPNode()
|
||||
self.back.build(back)
|
||||
|
||||
|
||||
class CSG:
|
||||
"""
|
||||
Constructive Solid Geometry (CSG) is a modeling technique that uses Boolean
|
||||
operations like union and intersection to combine 3D solids. This class
|
||||
implements CSG operations on meshes.
|
||||
|
||||
New 3D solids are created from :class:`~ezdxf.render.MeshBuilder` objects
|
||||
and results can be exported as :class:`~ezdxf.render.MeshTransformer` objects
|
||||
to `ezdxf` by method :meth:`mesh`.
|
||||
|
||||
Args:
|
||||
mesh: :class:`ezdxf.render.MeshBuilder` or inherited object
|
||||
meshid: individual mesh ID to separate result meshes, ``0`` is default
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, mesh: Optional[MeshBuilder] = None, meshid: int = 0):
|
||||
if mesh is None:
|
||||
self.polygons: list[Polygon] = []
|
||||
else:
|
||||
mesh_copy = mesh.copy()
|
||||
mesh_copy.normalize_faces()
|
||||
self.polygons = [
|
||||
Polygon(face, meshid) for face in mesh_copy.faces_as_vertices()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def from_polygons(cls, polygons: list[Polygon]) -> CSG:
|
||||
csg = CSG()
|
||||
csg.polygons = polygons
|
||||
return csg
|
||||
|
||||
def mesh(self, meshid: int = 0) -> MeshTransformer:
|
||||
"""
|
||||
Returns a :class:`ezdxf.render.MeshTransformer` object.
|
||||
|
||||
Args:
|
||||
meshid: individual mesh ID, ``0`` is default
|
||||
|
||||
"""
|
||||
mesh = MeshVertexMerger()
|
||||
for face in self.polygons:
|
||||
if meshid == face.meshid:
|
||||
mesh.add_face(face.vertices)
|
||||
return MeshTransformer.from_builder(mesh)
|
||||
|
||||
def clone(self) -> CSG:
|
||||
return self.from_polygons([p.clone() for p in self.polygons])
|
||||
|
||||
def union(self, other: CSG) -> CSG:
|
||||
"""
|
||||
Return a new CSG solid representing space in either this solid or in the
|
||||
solid `other`. Neither this solid nor the solid `other` are modified::
|
||||
|
||||
A.union(B)
|
||||
|
||||
+-------+ +-------+
|
||||
| | | |
|
||||
| A | | |
|
||||
| +--+----+ = | +----+
|
||||
+----+--+ | +----+ |
|
||||
| B | | |
|
||||
| | | |
|
||||
+-------+ +-------+
|
||||
"""
|
||||
a = BSPNode(self.clone().polygons)
|
||||
b = BSPNode(other.clone().polygons)
|
||||
a.clip_to(b)
|
||||
b.clip_to(a)
|
||||
b.invert()
|
||||
b.clip_to(a)
|
||||
b.invert()
|
||||
a.build(b.all_polygons())
|
||||
return CSG.from_polygons(a.all_polygons())
|
||||
|
||||
__add__ = union
|
||||
|
||||
def subtract(self, other: CSG) -> CSG:
|
||||
"""
|
||||
Return a new CSG solid representing space in this solid but not in the
|
||||
solid `other`. Neither this solid nor the solid `other` are modified::
|
||||
|
||||
A.subtract(B)
|
||||
|
||||
+-------+ +-------+
|
||||
| | | |
|
||||
| A | | |
|
||||
| +--+----+ = | +--+
|
||||
+----+--+ | +----+
|
||||
| B |
|
||||
| |
|
||||
+-------+
|
||||
"""
|
||||
a = BSPNode(self.clone().polygons)
|
||||
b = BSPNode(other.clone().polygons)
|
||||
a.invert()
|
||||
a.clip_to(b)
|
||||
b.clip_to(a)
|
||||
b.invert()
|
||||
b.clip_to(a)
|
||||
b.invert()
|
||||
a.build(b.all_polygons())
|
||||
a.invert()
|
||||
return CSG.from_polygons(a.all_polygons())
|
||||
|
||||
__sub__ = subtract
|
||||
|
||||
def intersect(self, other: CSG) -> CSG:
|
||||
"""
|
||||
Return a new CSG solid representing space both this solid and in the
|
||||
solid `other`. Neither this solid nor the solid `other` are modified::
|
||||
|
||||
A.intersect(B)
|
||||
|
||||
+-------+
|
||||
| |
|
||||
| A |
|
||||
| +--+----+ = +--+
|
||||
+----+--+ | +--+
|
||||
| B |
|
||||
| |
|
||||
+-------+
|
||||
"""
|
||||
a = BSPNode(self.clone().polygons)
|
||||
b = BSPNode(other.clone().polygons)
|
||||
a.invert()
|
||||
b.clip_to(a)
|
||||
b.invert()
|
||||
a.clip_to(b)
|
||||
b.clip_to(a)
|
||||
a.build(b.all_polygons())
|
||||
a.invert()
|
||||
return CSG.from_polygons(a.all_polygons())
|
||||
|
||||
__mul__ = intersect
|
||||
|
||||
def inverse(self) -> CSG:
|
||||
"""
|
||||
Return a new CSG solid with solid and empty space switched. This solid is
|
||||
not modified.
|
||||
"""
|
||||
csg = self.clone()
|
||||
for p in csg.polygons:
|
||||
p.flip()
|
||||
return csg
|
||||
@@ -0,0 +1,636 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
"""
|
||||
Module to export any DXF document as DXF version R12 without modifying the source
|
||||
document.
|
||||
|
||||
.. versionadded:: 1.1
|
||||
|
||||
To get the best result use the ODA File Converter add-on::
|
||||
|
||||
from ezdxf.addons import odafc
|
||||
|
||||
odafc.convert("any.dxf", "r12.dxf", version="R12")
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, TextIO, Callable, Optional
|
||||
import os
|
||||
from io import StringIO
|
||||
import logging
|
||||
|
||||
import ezdxf
|
||||
from ezdxf import const, proxygraphic, path
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.entities import (
|
||||
BlockRecord,
|
||||
DXFEntity,
|
||||
DXFTagStorage,
|
||||
Ellipse,
|
||||
Hatch,
|
||||
Insert,
|
||||
LWPolyline,
|
||||
MPolygon,
|
||||
MText,
|
||||
Mesh,
|
||||
Polyface,
|
||||
Polyline,
|
||||
Spline,
|
||||
Textstyle,
|
||||
)
|
||||
from ezdxf.entities.polygon import DXFPolygon
|
||||
from ezdxf.addons import MTextExplode
|
||||
from ezdxf.entitydb import EntitySpace
|
||||
from ezdxf.layouts import BlockLayout, VirtualLayout
|
||||
from ezdxf.lldxf.tagwriter import TagWriter, AbstractTagWriter
|
||||
from ezdxf.lldxf.types import DXFTag, TAG_STRING_FORMAT
|
||||
from ezdxf.math import Z_AXIS, Vec3, NULLVEC
|
||||
from ezdxf.r12strict import R12NameTranslator
|
||||
from ezdxf.render import MeshBuilder
|
||||
from ezdxf.sections.table import TextstyleTable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.eztypes import GenericLayoutType
|
||||
|
||||
__all__ = ["R12Exporter", "convert", "saveas", "write"]
|
||||
|
||||
MAX_SAGITTA = 0.01
|
||||
logger = logging.getLogger("ezdxf")
|
||||
|
||||
|
||||
def convert(doc: Drawing, *, max_sagitta: float = MAX_SAGITTA) -> Drawing:
|
||||
"""Export and reload DXF document as DXF version R12.
|
||||
|
||||
Writes the DXF document into a temporary file at the file-system and reloads this
|
||||
file by the :func:`ezdxf.readfile` function.
|
||||
"""
|
||||
stream = StringIO()
|
||||
exporter = R12Exporter(doc, max_sagitta=max_sagitta)
|
||||
exporter.write(stream)
|
||||
stream.seek(0)
|
||||
return ezdxf.read(stream)
|
||||
|
||||
|
||||
def write(doc: Drawing, stream: TextIO, *, max_sagitta: float = MAX_SAGITTA) -> None:
|
||||
"""Write a DXF document as DXF version R12 to a text stream. The `max_sagitta`
|
||||
argument determines the accuracy of the curve flatting for SPLINE and ELLIPSE
|
||||
entities.
|
||||
|
||||
Args:
|
||||
doc: DXF document to export
|
||||
stream: output stream, use :attr:`doc.encoding` as encoding
|
||||
max_sagitta: maximum distance from the center of the curve to the
|
||||
center of the line segment between two approximation points to
|
||||
determine if a segment should be subdivided.
|
||||
|
||||
"""
|
||||
exporter = R12Exporter(doc, max_sagitta=max_sagitta)
|
||||
exporter.write(stream)
|
||||
|
||||
|
||||
def saveas(
|
||||
doc: Drawing, filepath: str | os.PathLike, *, max_sagitta: float = MAX_SAGITTA
|
||||
) -> None:
|
||||
"""Write a DXF document as DXF version R12 to a file. The `max_sagitta`
|
||||
argument determines the accuracy of the curve flatting for SPLINE and ELLIPSE
|
||||
entities.
|
||||
|
||||
Args:
|
||||
doc: DXF document to export
|
||||
filepath: output filename
|
||||
max_sagitta: maximum distance from the center of the curve to the
|
||||
center of the line segment between two approximation points to
|
||||
determine if a segment should be subdivided.
|
||||
|
||||
"""
|
||||
with open(filepath, "wt", encoding=doc.encoding, errors="dxfreplace") as stream:
|
||||
write(
|
||||
doc,
|
||||
stream,
|
||||
max_sagitta=max_sagitta,
|
||||
)
|
||||
|
||||
|
||||
def spline_to_polyline(
|
||||
spline: Spline, max_sagitta: float, min_segments: int
|
||||
) -> Polyline:
|
||||
polyline = Polyline.new(
|
||||
dxfattribs={
|
||||
"layer": spline.dxf.layer,
|
||||
"linetype": spline.dxf.linetype,
|
||||
"color": spline.dxf.color,
|
||||
"flags": const.POLYLINE_3D_POLYLINE,
|
||||
}
|
||||
)
|
||||
|
||||
polyline.append_vertices(points=spline.flattening(max_sagitta, min_segments))
|
||||
polyline.new_seqend()
|
||||
return polyline
|
||||
|
||||
|
||||
def ellipse_to_polyline(
|
||||
ellipse: Ellipse, max_sagitta: float, min_segments: int
|
||||
) -> Polyline:
|
||||
polyline = Polyline.new(
|
||||
dxfattribs={
|
||||
"layer": ellipse.dxf.layer,
|
||||
"linetype": ellipse.dxf.linetype,
|
||||
"color": ellipse.dxf.color,
|
||||
"flags": const.POLYLINE_3D_POLYLINE,
|
||||
}
|
||||
)
|
||||
polyline.append_vertices(points=ellipse.flattening(max_sagitta, min_segments))
|
||||
polyline.new_seqend()
|
||||
return polyline
|
||||
|
||||
|
||||
def lwpolyline_to_polyline(lwpolyline: LWPolyline) -> Polyline:
|
||||
polyline = Polyline.new(
|
||||
dxfattribs={
|
||||
"layer": lwpolyline.dxf.layer,
|
||||
"linetype": lwpolyline.dxf.linetype,
|
||||
"color": lwpolyline.dxf.color,
|
||||
}
|
||||
)
|
||||
polyline.new_seqend()
|
||||
polyline.append_formatted_vertices(lwpolyline.get_points(), format="xyseb")
|
||||
if lwpolyline.is_closed:
|
||||
polyline.close()
|
||||
if lwpolyline.dxf.hasattr("const_width"):
|
||||
width = lwpolyline.dxf.const_width
|
||||
polyline.dxf.default_start_width = width
|
||||
polyline.dxf.default_end_width = width
|
||||
extrusion = Vec3(lwpolyline.dxf.extrusion)
|
||||
if not extrusion.isclose(Z_AXIS):
|
||||
polyline.dxf.extrusion = extrusion
|
||||
elevation = lwpolyline.dxf.elevation
|
||||
polyline.dxf.elevation = Vec3(0, 0, elevation)
|
||||
# Set z-axis of VERTEX.location to elevation?
|
||||
|
||||
return polyline
|
||||
|
||||
|
||||
def mesh_to_polyface_mesh(mesh: Mesh) -> Polyface:
|
||||
builder = MeshBuilder.from_mesh(mesh)
|
||||
return builder.render_polyface(
|
||||
VirtualLayout(),
|
||||
dxfattribs={
|
||||
"layer": mesh.dxf.layer,
|
||||
"linetype": mesh.dxf.linetype,
|
||||
"color": mesh.dxf.color,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_xpl_block_name(entity: DXFEntity) -> str:
|
||||
assert entity.dxf.handle is not None
|
||||
return f"EZDXF_XPL_{entity.dxftype()}_{entity.dxf.handle}"
|
||||
|
||||
|
||||
def export_lwpolyline(exporter: R12Exporter, entity: DXFEntity):
|
||||
assert isinstance(entity, LWPolyline)
|
||||
polyline = lwpolyline_to_polyline(entity)
|
||||
if len(polyline.vertices):
|
||||
polyline.export_dxf(exporter.tagwriter())
|
||||
|
||||
|
||||
def export_mesh(exporter: R12Exporter, entity: DXFEntity):
|
||||
assert isinstance(entity, Mesh)
|
||||
polyface_mesh = mesh_to_polyface_mesh(entity)
|
||||
if len(polyface_mesh.vertices):
|
||||
polyface_mesh.export_dxf(exporter.tagwriter())
|
||||
|
||||
|
||||
def export_spline(exporter: R12Exporter, entity: DXFEntity):
|
||||
assert isinstance(entity, Spline)
|
||||
polyline = spline_to_polyline(
|
||||
entity, exporter.max_sagitta, exporter.min_spline_segments
|
||||
)
|
||||
if len(polyline.vertices):
|
||||
polyline.export_dxf(exporter.tagwriter())
|
||||
|
||||
|
||||
def export_ellipse(exporter: R12Exporter, entity: DXFEntity):
|
||||
assert isinstance(entity, Ellipse)
|
||||
polyline = ellipse_to_polyline(
|
||||
entity, exporter.max_sagitta, exporter.min_ellipse_segments
|
||||
)
|
||||
if len(polyline.vertices):
|
||||
polyline.export_dxf(exporter.tagwriter())
|
||||
|
||||
|
||||
def make_insert(name: str, entity: DXFEntity, location=NULLVEC) -> Insert:
|
||||
return Insert.new(
|
||||
dxfattribs={
|
||||
"name": name,
|
||||
"layer": entity.dxf.layer,
|
||||
"linetype": entity.dxf.linetype,
|
||||
"color": entity.dxf.color,
|
||||
"insert": location,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def export_proxy_graphic(exporter: R12Exporter, entity: DXFEntity):
|
||||
assert isinstance(entity.proxy_graphic, bytes)
|
||||
pg = proxygraphic.ProxyGraphic(entity.proxy_graphic)
|
||||
try:
|
||||
entities = EntitySpace(pg.virtual_entities())
|
||||
except proxygraphic.ProxyGraphicError:
|
||||
return
|
||||
exporter.export_entity_space(entities)
|
||||
|
||||
|
||||
def export_mtext(exporter: R12Exporter, entity: DXFEntity):
|
||||
assert isinstance(entity, MText)
|
||||
layout = VirtualLayout()
|
||||
exporter.explode_mtext(entity, layout)
|
||||
exporter.export_entity_space(layout.entity_space)
|
||||
|
||||
|
||||
def export_virtual_entities(exporter: R12Exporter, entity: DXFEntity):
|
||||
layout = VirtualLayout()
|
||||
try:
|
||||
for e in entity.virtual_entities(): # type: ignore
|
||||
layout.add_entity(e)
|
||||
except Exception:
|
||||
return
|
||||
exporter.export_entity_space(layout.entity_space)
|
||||
|
||||
|
||||
def export_pattern_fill(entity: DXFEntity, block: BlockLayout) -> None:
|
||||
assert isinstance(entity, DXFPolygon)
|
||||
dxfattribs = {
|
||||
"layer": entity.dxf.layer,
|
||||
"color": entity.dxf.color,
|
||||
}
|
||||
for start, end in entity.render_pattern_lines():
|
||||
block.add_line(start, end, dxfattribs=dxfattribs)
|
||||
|
||||
|
||||
def export_solid_fill(
|
||||
entity: DXFPolygon,
|
||||
block: BlockLayout,
|
||||
max_sagitta: float,
|
||||
min_segments: int,
|
||||
) -> None:
|
||||
dxfattribs = {
|
||||
"layer": entity.dxf.layer,
|
||||
"color": entity.dxf.color,
|
||||
}
|
||||
|
||||
extrusion = Vec3(entity.dxf.extrusion)
|
||||
if not extrusion.is_null and not extrusion.isclose(Z_AXIS):
|
||||
dxfattribs["extrusion"] = extrusion
|
||||
|
||||
# triangulation in OCS coordinates, including elevation and offset values:
|
||||
for vertices in entity.triangulate(max_sagitta, min_segments):
|
||||
block.add_solid(vertices, dxfattribs=dxfattribs)
|
||||
|
||||
|
||||
def export_hatch(exporter: R12Exporter, entity: DXFEntity) -> None:
|
||||
assert isinstance(entity, Hatch)
|
||||
# export hatch into an anonymous block
|
||||
block = exporter.new_block(entity)
|
||||
insert = make_insert(block.name, entity)
|
||||
insert.export_dxf(exporter.tagwriter())
|
||||
|
||||
if entity.has_pattern_fill:
|
||||
export_pattern_fill(entity, block)
|
||||
else:
|
||||
export_solid_fill(
|
||||
entity, block, exporter.max_sagitta, exporter.min_spline_segments
|
||||
)
|
||||
|
||||
|
||||
def export_mpolygon(exporter: R12Exporter, entity: DXFEntity) -> None:
|
||||
assert isinstance(entity, MPolygon)
|
||||
# export mpolygon into an anonymous block
|
||||
block = exporter.new_block(entity)
|
||||
insert = make_insert(block.name, entity)
|
||||
insert.export_dxf(exporter.tagwriter())
|
||||
|
||||
# elevation is the z-axis of the vertices
|
||||
path.render_polylines2d(
|
||||
block,
|
||||
path.from_hatch_ocs(entity, offset=Vec3(entity.dxf.offset)),
|
||||
distance=exporter.max_sagitta,
|
||||
segments=exporter.min_spline_segments,
|
||||
extrusion=Vec3(entity.dxf.extrusion),
|
||||
dxfattribs={
|
||||
"layer": entity.dxf.layer,
|
||||
"linetype": entity.dxf.linetype,
|
||||
"color": entity.dxf.color,
|
||||
},
|
||||
)
|
||||
if entity.has_pattern_fill:
|
||||
export_pattern_fill(entity, block)
|
||||
else:
|
||||
export_solid_fill(
|
||||
entity, block, exporter.max_sagitta, exporter.min_spline_segments
|
||||
)
|
||||
|
||||
|
||||
def export_acad_table(exporter: R12Exporter, entity: DXFEntity) -> None:
|
||||
from ezdxf.entities.acad_table import AcadTableBlockContent
|
||||
|
||||
assert isinstance(entity, AcadTableBlockContent)
|
||||
table: AcadTableBlockContent = entity
|
||||
location = table.get_insert_location()
|
||||
block_name = table.get_block_name()
|
||||
if not block_name.startswith("*T"):
|
||||
return
|
||||
try:
|
||||
acdb_entity = table.xtags.get_subclass("AcDbEntity")
|
||||
except const.DXFIndexError:
|
||||
return
|
||||
layer = acdb_entity.get_first_value(8, "0")
|
||||
insert = Insert.new(
|
||||
dxfattribs={"name": block_name, "layer": layer, "insert": location}
|
||||
)
|
||||
insert.export_dxf(exporter.tagwriter())
|
||||
|
||||
|
||||
# Planned features: explode complex newer entity types into DXF primitives.
|
||||
# currently skipped entity types:
|
||||
# - ACAD_TABLE: graphic as geometry block is available
|
||||
# --------------------------------------------------------------------------------------
|
||||
# - all ACIS based entities: tessellated meshes could be exported, but very much work
|
||||
# and beyond my current knowledge
|
||||
# - IMAGE and UNDERLAY: no support possible
|
||||
# - XRAY and XLINE: no support possible (infinite lines)
|
||||
|
||||
# Possible name tags to translate:
|
||||
# 1 The primary text value for an entity - never a name
|
||||
# 2 A name: Attribute tag, Block name, and so on. Also used to identify a DXF section or
|
||||
# table name
|
||||
# 3 Other textual or name values - only in DIMENSION a name
|
||||
# 4 Other textual values - never a name!
|
||||
# 5 Entity handle expressed as a hexadecimal string (fixed)
|
||||
# 6 Line type name (fixed)
|
||||
# 7 Text style name (fixed)
|
||||
# 8 Layer name (fixed)
|
||||
# 1001: AppID
|
||||
# 1003: layer name in XDATA (fixed)
|
||||
NAME_TAG_CODES = {2, 3, 6, 7, 8, 1001, 1003}
|
||||
|
||||
|
||||
class R12TagWriter(TagWriter):
|
||||
def __init__(self, stream: TextIO):
|
||||
super().__init__(stream, dxfversion=const.DXF12, write_handles=False)
|
||||
self.skip_xdata = False
|
||||
self.current_entity = ""
|
||||
self.translator = R12NameTranslator()
|
||||
|
||||
def set_stream(self, stream: TextIO) -> None:
|
||||
self._stream = stream
|
||||
|
||||
def write_tag(self, tag: DXFTag) -> None:
|
||||
code, value = tag
|
||||
if code == 0:
|
||||
self.current_entity = str(value)
|
||||
if code > 999 and self.skip_xdata:
|
||||
return
|
||||
if code in NAME_TAG_CODES:
|
||||
self._stream.write(
|
||||
TAG_STRING_FORMAT % (code, self.sanitize_name(code, value))
|
||||
)
|
||||
else:
|
||||
self._stream.write(tag.dxfstr())
|
||||
|
||||
def write_tag2(self, code: int, value) -> None:
|
||||
if code > 999 and self.skip_xdata:
|
||||
return
|
||||
if code == 0:
|
||||
self.current_entity = str(value)
|
||||
if code in NAME_TAG_CODES:
|
||||
value = self.sanitize_name(code, value)
|
||||
self._stream.write(TAG_STRING_FORMAT % (code, value))
|
||||
|
||||
def sanitize_name(self, code: int, name: str) -> str:
|
||||
# sanitize group code 3 + 4
|
||||
# LTYPE - <description> has group code - not a table name
|
||||
# STYLE - <font> has group code (3) - not a table name
|
||||
# STYLE - <bigfont> has group code (4) - not a table name
|
||||
# DIMSTYLE - <dimpost> has group code e.g. "<> mm" (3) - not a table name
|
||||
# DIMSTYLE - <dimapost> has group code (4) - not a table name
|
||||
# ATTDEF - <prompt> has group code (3) - not a table name
|
||||
# DIMENSION - <dimstyle> has group code (3) - is a table name!
|
||||
if code == 3 and self.current_entity != "DIMENSION":
|
||||
return name
|
||||
return self.translator.translate(name)
|
||||
|
||||
|
||||
class SpecialStyleTable:
|
||||
def __init__(self, styles: TextstyleTable, extra_styles: TextstyleTable):
|
||||
self.styles = styles
|
||||
self.extra_styles = extra_styles
|
||||
|
||||
def get_text_styles(self) -> list[Textstyle]:
|
||||
entries = list(self.styles.entries.values())
|
||||
for name, extra_style in self.extra_styles.entries.items():
|
||||
if not self.styles.has_entry(name):
|
||||
entries.append(extra_style)
|
||||
return entries
|
||||
|
||||
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
|
||||
text_styles = self.get_text_styles()
|
||||
tagwriter.write_tag2(0, "TABLE")
|
||||
tagwriter.write_tag2(2, "STYLE")
|
||||
tagwriter.write_tag2(70, len(text_styles))
|
||||
for style in text_styles:
|
||||
style.export_dxf(tagwriter)
|
||||
tagwriter.write_tag2(0, "ENDTAB")
|
||||
|
||||
|
||||
EOF_STR = "0\nEOF\n"
|
||||
|
||||
|
||||
def detect_max_block_number(names: list[str]) -> int:
|
||||
max_number = 0
|
||||
for name in names:
|
||||
name = name.upper()
|
||||
if not name.startswith("*"):
|
||||
continue
|
||||
try: # *U10
|
||||
number = int(name[2:])
|
||||
except ValueError:
|
||||
continue
|
||||
max_number = max(max_number, number)
|
||||
return max_number + 1
|
||||
|
||||
|
||||
class R12Exporter:
|
||||
def __init__(self, doc: Drawing, max_sagitta: float = 0.01):
|
||||
assert isinstance(doc, Drawing)
|
||||
self._doc = doc
|
||||
self._tagwriter = R12TagWriter(StringIO())
|
||||
self.max_sagitta = float(max_sagitta) # flattening SPLINE, ELLIPSE
|
||||
self.min_spline_segments: int = 4 # flattening SPLINE
|
||||
self.min_ellipse_segments: int = 8 # flattening ELLIPSE
|
||||
self._extra_doc = ezdxf.new("R12")
|
||||
self._next_block_number = detect_max_block_number(
|
||||
[br.dxf.name for br in doc.block_records]
|
||||
)
|
||||
# Exporters are required to convert newer entity types into DXF R12 types.
|
||||
# All newer entity types without an exporter will be ignored.
|
||||
self.exporters: dict[str, Callable[[R12Exporter, DXFEntity], None]] = {
|
||||
"LWPOLYLINE": export_lwpolyline,
|
||||
"MESH": export_mesh,
|
||||
"SPLINE": export_spline,
|
||||
"ELLIPSE": export_ellipse,
|
||||
"MTEXT": export_mtext,
|
||||
"LEADER": export_virtual_entities,
|
||||
"MLEADER": export_virtual_entities,
|
||||
"MULTILEADER": export_virtual_entities,
|
||||
"MLINE": export_virtual_entities,
|
||||
"HATCH": export_hatch,
|
||||
"MPOLYGON": export_mpolygon,
|
||||
"ACAD_TABLE": export_acad_table,
|
||||
}
|
||||
|
||||
def disable_exporter(self, entity_type: str):
|
||||
del self.exporters[entity_type]
|
||||
|
||||
@property
|
||||
def doc(self) -> Drawing:
|
||||
return self._doc
|
||||
|
||||
def tagwriter(self, stream: Optional[TextIO] = None) -> R12TagWriter:
|
||||
if stream is not None:
|
||||
self._tagwriter.set_stream(stream)
|
||||
return self._tagwriter
|
||||
|
||||
def write(self, stream: TextIO) -> None:
|
||||
"""Write DXF document to text stream."""
|
||||
stream.write(self.to_string())
|
||||
|
||||
def to_string(self) -> str:
|
||||
"""Export DXF document as string."""
|
||||
# export layouts before blocks: may create new anonymous blocks
|
||||
entities = self.export_layouts_to_string()
|
||||
# export blocks before HEADER and TABLES sections: may create new text styles
|
||||
blocks = self.export_blocks_to_string()
|
||||
|
||||
return "".join(
|
||||
(
|
||||
self.export_header_to_string(),
|
||||
self.export_tables_to_string(),
|
||||
blocks,
|
||||
entities,
|
||||
EOF_STR,
|
||||
)
|
||||
)
|
||||
|
||||
def next_block_name(self, char: str) -> str:
|
||||
name = f"*{char}{self._next_block_number}"
|
||||
self._next_block_number += 1
|
||||
return name
|
||||
|
||||
def new_block(self, entity: DXFEntity) -> BlockLayout:
|
||||
name = self.next_block_name("U")
|
||||
return self._extra_doc.blocks.new(
|
||||
name,
|
||||
dxfattribs={
|
||||
"layer": entity.dxf.get("layer", "0"),
|
||||
"flags": const.BLK_ANONYMOUS,
|
||||
},
|
||||
)
|
||||
|
||||
def export_header_to_string(self) -> str:
|
||||
in_memory_stream = StringIO()
|
||||
self.doc.header.export_dxf(self.tagwriter(in_memory_stream))
|
||||
return in_memory_stream.getvalue()
|
||||
|
||||
def export_tables_to_string(self) -> str:
|
||||
# DXF R12 does not support XDATA in tables according Autodesk DWG TrueView
|
||||
in_memory_stream = StringIO()
|
||||
tables = self.doc.tables
|
||||
preserve_table = tables.styles
|
||||
tables.styles = SpecialStyleTable(self.doc.styles, self._extra_doc.styles) # type: ignore
|
||||
|
||||
tagwriter = self.tagwriter(in_memory_stream)
|
||||
tagwriter.skip_xdata = True
|
||||
tables.export_dxf(tagwriter)
|
||||
tables.styles = preserve_table
|
||||
tagwriter.skip_xdata = False
|
||||
return in_memory_stream.getvalue()
|
||||
|
||||
def export_blocks_to_string(self) -> str:
|
||||
in_memory_stream = StringIO()
|
||||
self._tagwriter.set_stream(in_memory_stream)
|
||||
|
||||
self._write_section_header("BLOCKS")
|
||||
for block_record in self.doc.block_records:
|
||||
if block_record.is_any_paperspace and not block_record.is_active_paperspace:
|
||||
continue
|
||||
name = block_record.dxf.name.lower()
|
||||
if name in ("$model_space", "$paper_space"):
|
||||
# These block names collide with the translated names of the *Model_Space
|
||||
# and the *Paper_Space blocks.
|
||||
continue
|
||||
self._export_block_record(block_record)
|
||||
|
||||
extra_blocks = self.get_extra_blocks()
|
||||
while len(extra_blocks):
|
||||
for block_record in extra_blocks:
|
||||
self._export_block_record(block_record)
|
||||
self.discard_extra_block(block_record.dxf.name)
|
||||
# block record export can create further blocks
|
||||
extra_blocks = self.get_extra_blocks()
|
||||
|
||||
self._write_endsec()
|
||||
return in_memory_stream.getvalue()
|
||||
|
||||
def discard_extra_block(self, name: str) -> None:
|
||||
self._extra_doc.block_records.discard(name)
|
||||
|
||||
def get_extra_blocks(self) -> list[BlockRecord]:
|
||||
return [
|
||||
br for br in self._extra_doc.block_records if br.dxf.name.startswith("*U")
|
||||
]
|
||||
|
||||
def explode_mtext(self, mtext: MText, layout: GenericLayoutType):
|
||||
with MTextExplode(layout, self._extra_doc) as xpl:
|
||||
xpl.explode(mtext, destroy=False)
|
||||
|
||||
def export_layouts_to_string(self) -> str:
|
||||
in_memory_stream = StringIO()
|
||||
self._tagwriter.set_stream(in_memory_stream)
|
||||
|
||||
self._write_section_header("ENTITIES")
|
||||
self.export_entity_space(self.doc.modelspace().entity_space)
|
||||
self.export_entity_space(self.doc.paperspace().entity_space)
|
||||
self._write_endsec()
|
||||
return in_memory_stream.getvalue()
|
||||
|
||||
def _export_block_record(self, block_record: BlockRecord):
|
||||
tagwriter = self._tagwriter
|
||||
assert block_record.block is not None
|
||||
block_record.block.export_dxf(tagwriter)
|
||||
if not block_record.is_any_layout:
|
||||
self.export_entity_space(block_record.entity_space)
|
||||
assert block_record.endblk is not None
|
||||
block_record.endblk.export_dxf(tagwriter)
|
||||
|
||||
def export_entity_space(self, space: EntitySpace):
|
||||
tagwriter = self._tagwriter
|
||||
for entity in space:
|
||||
if entity.MIN_DXF_VERSION_FOR_EXPORT > const.DXF12 or isinstance(
|
||||
entity, DXFTagStorage
|
||||
):
|
||||
exporter = self.exporters.get(entity.dxftype())
|
||||
if exporter:
|
||||
exporter(self, entity)
|
||||
continue
|
||||
if entity.proxy_graphic:
|
||||
export_proxy_graphic(self, entity)
|
||||
else:
|
||||
entity.export_dxf(tagwriter)
|
||||
|
||||
def _write_section_header(self, name: str) -> None:
|
||||
self._tagwriter.write_str(f" 0\nSECTION\n 2\n{name}\n")
|
||||
|
||||
def _write_endsec(self) -> None:
|
||||
self._tagwriter.write_tag2(0, "ENDSEC")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
# Purpose: create sierpinski pyramid geometry
|
||||
# Copyright (c) 2016-2022 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Iterable, Sequence, Iterator, Optional
|
||||
import math
|
||||
from ezdxf.math import Vec3, UVec, Matrix44, UCS
|
||||
from ezdxf.render.mesh import MeshVertexMerger, MeshTransformer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.eztypes import GenericLayoutType
|
||||
|
||||
HEIGHT4 = 1.0 / math.sqrt(2.0) # pyramid4 height (* length)
|
||||
HEIGHT3 = math.sqrt(6.0) / 3.0 # pyramid3 height (* length)
|
||||
|
||||
DY1_FACTOR = math.tan(math.pi / 6.0) / 2.0 # inner circle radius
|
||||
DY2_FACTOR = 0.5 / math.cos(math.pi / 6.0) # outer circle radius
|
||||
|
||||
|
||||
class SierpinskyPyramid:
|
||||
"""
|
||||
Args:
|
||||
location: location of base center as (x, y, z) tuple
|
||||
length: side length
|
||||
level: subdivide level
|
||||
sides: sides of base geometry
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
location: UVec = (0.0, 0.0, 0.0),
|
||||
length: float = 1.0,
|
||||
level: int = 1,
|
||||
sides: int = 4,
|
||||
):
|
||||
self.sides = sides
|
||||
self.pyramid_definitions = sierpinsky_pyramid(
|
||||
location=location, length=length, level=level, sides=sides
|
||||
)
|
||||
|
||||
def vertices(self) -> Iterator[list[Vec3]]:
|
||||
"""Yields the pyramid vertices as list of :class:`ezdxf.math.Vec3`."""
|
||||
for location, length in self.pyramid_definitions:
|
||||
yield self._calc_vertices(location, length)
|
||||
|
||||
__iter__ = vertices
|
||||
|
||||
def _calc_vertices(
|
||||
self, location: UVec, length: float
|
||||
) -> list[Vec3]:
|
||||
"""
|
||||
Calculates the pyramid vertices.
|
||||
|
||||
Args:
|
||||
location: location of the pyramid as center point of the base
|
||||
length: pyramid side length
|
||||
|
||||
Returns: list of :class:`ezdxf.math.Vec3`
|
||||
|
||||
"""
|
||||
len2 = length / 2.0
|
||||
x, y, z = location
|
||||
if self.sides == 4:
|
||||
return [
|
||||
Vec3(x - len2, y - len2, z),
|
||||
Vec3(x + len2, y - len2, z),
|
||||
Vec3(x + len2, y + len2, z),
|
||||
Vec3(x - len2, y + len2, z),
|
||||
Vec3(x, y, z + length * HEIGHT4),
|
||||
]
|
||||
elif self.sides == 3:
|
||||
dy1 = length * DY1_FACTOR
|
||||
dy2 = length * DY2_FACTOR
|
||||
return [
|
||||
Vec3(x - len2, y - dy1, z),
|
||||
Vec3(x + len2, y - dy1, z),
|
||||
Vec3(x, y + dy2, z),
|
||||
Vec3(x, y, z + length * HEIGHT3),
|
||||
]
|
||||
else:
|
||||
raise ValueError("sides has to be 3 or 4.")
|
||||
|
||||
def faces(self) -> list[Sequence[int]]:
|
||||
"""Returns list of pyramid faces. All pyramid vertices have the same
|
||||
order, so one faces list fits them all.
|
||||
|
||||
"""
|
||||
if self.sides == 4:
|
||||
return [(3, 2, 1, 0), (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4)]
|
||||
elif self.sides == 3:
|
||||
return [(2, 1, 0), (0, 1, 3), (1, 2, 3), (2, 0, 3)]
|
||||
else:
|
||||
raise ValueError("sides has to be 3 or 4.")
|
||||
|
||||
def render(
|
||||
self,
|
||||
layout: GenericLayoutType,
|
||||
merge: bool = False,
|
||||
dxfattribs=None,
|
||||
matrix: Optional[Matrix44] = None,
|
||||
ucs: Optional[UCS] = None,
|
||||
) -> None:
|
||||
"""Renders the sierpinsky pyramid into layout, set `merge` to ``True``
|
||||
for rendering the whole sierpinsky pyramid into one MESH entity, set
|
||||
`merge` to ``False`` for individual pyramids as MESH entities.
|
||||
|
||||
Args:
|
||||
layout: DXF target layout
|
||||
merge: ``True`` for one MESH entity, ``False`` for individual MESH
|
||||
entities per pyramid
|
||||
dxfattribs: DXF attributes for the MESH entities
|
||||
matrix: apply transformation matrix at rendering
|
||||
ucs: apply UCS at rendering
|
||||
|
||||
"""
|
||||
if merge:
|
||||
mesh = self.mesh()
|
||||
mesh.render_mesh(
|
||||
layout, dxfattribs=dxfattribs, matrix=matrix, ucs=ucs
|
||||
)
|
||||
else:
|
||||
for pyramid in self.pyramids():
|
||||
pyramid.render_mesh(layout, dxfattribs, matrix=matrix, ucs=ucs)
|
||||
|
||||
def pyramids(self) -> Iterable[MeshTransformer]:
|
||||
"""Yields all pyramids of the sierpinsky pyramid as individual
|
||||
:class:`MeshTransformer` objects.
|
||||
"""
|
||||
faces = self.faces()
|
||||
for vertices in self:
|
||||
mesh = MeshTransformer()
|
||||
mesh.add_mesh(vertices=vertices, faces=faces)
|
||||
yield mesh
|
||||
|
||||
def mesh(self) -> MeshTransformer:
|
||||
"""Returns geometry as one :class:`MeshTransformer` object."""
|
||||
faces = self.faces()
|
||||
mesh = MeshVertexMerger()
|
||||
for vertices in self:
|
||||
mesh.add_mesh(vertices=vertices, faces=faces)
|
||||
return MeshTransformer.from_builder(mesh)
|
||||
|
||||
|
||||
def sierpinsky_pyramid(
|
||||
location=(0.0, 0.0, 0.0),
|
||||
length: float = 1.0,
|
||||
level: int = 1,
|
||||
sides: int = 4,
|
||||
) -> list[tuple[Vec3, float]]:
|
||||
"""Build a Sierpinski pyramid.
|
||||
|
||||
Args:
|
||||
location: base center point of the pyramid
|
||||
length: base length of the pyramid
|
||||
level: recursive building levels, has to 1 or bigger
|
||||
sides: 3 or 4 sided pyramids supported
|
||||
|
||||
Returns: list of pyramid vertices
|
||||
|
||||
"""
|
||||
location = Vec3(location)
|
||||
level = int(level)
|
||||
if level < 1:
|
||||
raise ValueError("level has to be 1 or bigger.")
|
||||
pyramids = _sierpinsky_pyramid(location, length, sides)
|
||||
for _ in range(level - 1):
|
||||
next_level_pyramids = []
|
||||
for location, length in pyramids:
|
||||
next_level_pyramids.extend(
|
||||
_sierpinsky_pyramid(location, length, sides)
|
||||
)
|
||||
pyramids = next_level_pyramids
|
||||
return pyramids
|
||||
|
||||
|
||||
def _sierpinsky_pyramid(
|
||||
location: Vec3, length: float = 1.0, sides: int = 4
|
||||
) -> list[tuple[Vec3, float]]:
|
||||
if sides == 3:
|
||||
return sierpinsky_pyramid_3(location, length)
|
||||
elif sides == 4:
|
||||
return sierpinsky_pyramid_4(location, length)
|
||||
else:
|
||||
raise ValueError("sides has to be 3 or 4.")
|
||||
|
||||
|
||||
def sierpinsky_pyramid_4(
|
||||
location: Vec3, length: float = 1.0
|
||||
) -> list[tuple[Vec3, float]]:
|
||||
"""Build a 4-sided Sierpinski pyramid. Pyramid height = length of the base
|
||||
square!
|
||||
|
||||
Args:
|
||||
location: base center point of the pyramid
|
||||
length: base length of the pyramid
|
||||
|
||||
Returns: list of (location, length) tuples, representing the sierpinski pyramid
|
||||
|
||||
"""
|
||||
len2 = length / 2
|
||||
len4 = length / 4
|
||||
x, y, z = location
|
||||
return [
|
||||
(Vec3(x - len4, y - len4, z), len2),
|
||||
(Vec3(x + len4, y - len4, z), len2),
|
||||
(Vec3(x - len4, y + len4, z), len2),
|
||||
(Vec3(x + len4, y + len4, z), len2),
|
||||
(Vec3(x, y, z + len2 * HEIGHT4), len2),
|
||||
]
|
||||
|
||||
|
||||
def sierpinsky_pyramid_3(
|
||||
location: Vec3, length: float = 1.0
|
||||
) -> list[tuple[Vec3, float]]:
|
||||
"""Build a 3-sided Sierpinski pyramid (tetraeder).
|
||||
|
||||
Args:
|
||||
location: base center point of the pyramid
|
||||
length: base length of the pyramid
|
||||
|
||||
Returns: list of (location, length) tuples, representing the sierpinski pyramid
|
||||
|
||||
"""
|
||||
dy1 = length * DY1_FACTOR * 0.5
|
||||
dy2 = length * DY2_FACTOR * 0.5
|
||||
len2 = length / 2
|
||||
len4 = length / 4
|
||||
x, y, z = location
|
||||
return [
|
||||
(Vec3(x - len4, y - dy1, z), len2),
|
||||
(Vec3(x + len4, y - dy1, z), len2),
|
||||
(Vec3(x, y + dy2, z), len2),
|
||||
(Vec3(x, y, z + len2 * HEIGHT3), len2),
|
||||
]
|
||||
@@ -0,0 +1,927 @@
|
||||
# Copyright (c) 2010-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Optional,
|
||||
Sequence,
|
||||
TYPE_CHECKING,
|
||||
TypeVar,
|
||||
)
|
||||
from copy import deepcopy
|
||||
|
||||
from ezdxf.lldxf import const
|
||||
from ezdxf.enums import (
|
||||
MTextEntityAlignment,
|
||||
MAP_MTEXT_ALIGN_TO_FLAGS,
|
||||
)
|
||||
from ezdxf.addons import MTextSurrogate
|
||||
from ezdxf.math import UVec, Vec2
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.layouts import BlockLayout
|
||||
from ezdxf.eztypes import GenericLayoutType
|
||||
|
||||
DEFAULT_TABLE_BG_LAYER = "TABLEBACKGROUND"
|
||||
DEFAULT_TABLE_FG_LAYER = "TABLECONTENT"
|
||||
DEFAULT_TABLE_GRID_LAYER = "TABLEGRID"
|
||||
DEFAULT_TEXT_STYLE = "STANDARD"
|
||||
DEFAULT_CELL_HEIGHT = 1.0
|
||||
DEFAULT_CELL_WIDTH = 2.5
|
||||
DEFAULT_CELL_CHAR_HEIGHT = 0.7
|
||||
DEFAULT_CELL_LINE_SPACING = 1.5
|
||||
DEFAULT_CELL_X_SCALE = 1.0
|
||||
DEFAULT_CELL_Y_SCALE = 1.0
|
||||
DEFAULT_CELL_TEXTCOLOR = const.BYLAYER
|
||||
DEFAULT_CELL_BG_COLOR = None
|
||||
DEFAULT_CELL_X_MARGIN = 0.1
|
||||
DEFAULT_CELL_Y_MARGIN = 0.1
|
||||
DEFAULT_BORDER_COLOR = 5
|
||||
DEFAULT_BORDER_LINETYPE = "BYLAYER"
|
||||
DEFAULT_BORDER_STATUS = True
|
||||
DEFAULT_BORDER_PRIORITY = 50
|
||||
|
||||
|
||||
T = TypeVar("T", bound="Cell")
|
||||
|
||||
|
||||
class TablePainter:
|
||||
"""The TablePainter class renders tables build from DXF primitives.
|
||||
|
||||
The TablePainter instance contains all the data cells.
|
||||
|
||||
Args:
|
||||
insert: insert location as or :class:`~ezdxf.math.UVec`
|
||||
nrows: row count
|
||||
ncols: column count
|
||||
cell_width: default cell width in drawing units
|
||||
cell_height: default cell height in drawing units
|
||||
default_grid: draw a grid of solid lines if ``True``, otherwise
|
||||
draw only explicit defined borders, the default grid has a
|
||||
priority of 50.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
insert: UVec,
|
||||
nrows: int,
|
||||
ncols: int,
|
||||
cell_width=DEFAULT_CELL_WIDTH,
|
||||
cell_height=DEFAULT_CELL_HEIGHT,
|
||||
default_grid=True,
|
||||
):
|
||||
self.insert = Vec2(insert)
|
||||
self.nrows: int = nrows
|
||||
self.ncols: int = ncols
|
||||
self.row_heights: list[float] = [cell_height] * nrows
|
||||
self.col_widths: list[float] = [cell_width] * ncols
|
||||
self.bg_layer_name: str = DEFAULT_TABLE_BG_LAYER
|
||||
self.fg_layer_name: str = DEFAULT_TABLE_FG_LAYER
|
||||
self.grid_layer_name: str = DEFAULT_TABLE_GRID_LAYER
|
||||
self.styles: dict[str, CellStyle] = {"default": CellStyle()}
|
||||
if not default_grid:
|
||||
default_style = self.get_cell_style("default")
|
||||
default_style.set_border_status(False, False, False, False)
|
||||
|
||||
self._cells: dict[tuple[int, int], Cell] = {} # data cells
|
||||
self.frames: list[Frame] = [] # border frame objects
|
||||
self.empty_cell = Cell(self) # represents all empty cells
|
||||
|
||||
def set_col_width(self, index: int, value: float):
|
||||
"""Set column width in drawing units of the given column index.
|
||||
|
||||
Args:
|
||||
index: zero based column index
|
||||
value: new column width in drawing units
|
||||
|
||||
"""
|
||||
self.col_widths[index] = float(value)
|
||||
|
||||
def set_row_height(self, index: int, value: float):
|
||||
"""Set row height in drawing units of the given row index.
|
||||
|
||||
Args:
|
||||
index: zero based row index
|
||||
value: new row height in drawing units
|
||||
"""
|
||||
self.row_heights[index] = float(value)
|
||||
|
||||
def text_cell(
|
||||
self,
|
||||
row: int,
|
||||
col: int,
|
||||
text: str,
|
||||
span: tuple[int, int] = (1, 1),
|
||||
style="default",
|
||||
) -> TextCell:
|
||||
"""Factory method to create a new text cell at location (row, col),
|
||||
with `text` as content, the `text` can be a line breaks ``'\\n'``.
|
||||
The final cell can spread over several cells defined by the argument
|
||||
`span`.
|
||||
|
||||
"""
|
||||
cell = TextCell(self, text, style=style, span=span)
|
||||
return self.set_cell(row, col, cell)
|
||||
|
||||
def block_cell(
|
||||
self,
|
||||
row: int,
|
||||
col: int,
|
||||
blockdef: BlockLayout,
|
||||
span: tuple[int, int] = (1, 1),
|
||||
attribs=None,
|
||||
style="default",
|
||||
) -> BlockCell:
|
||||
"""Factory method to Create a new block cell at position (row, col).
|
||||
|
||||
Content is a block reference inserted by an INSERT entity,
|
||||
attributes will be added if the block definition contains ATTDEF.
|
||||
Assignments are defined by attribs-key to attdef-tag association.
|
||||
|
||||
Example: attribs = {'num': 1} if an ATTDEF with tag=='num' in
|
||||
the block definition exists, an attrib with text=str(1) will be
|
||||
created and added to the insert entity.
|
||||
|
||||
The cell spans over 'span' cells and has the cell style with the
|
||||
name 'style'.
|
||||
"""
|
||||
if attribs is None:
|
||||
attribs = {}
|
||||
cell = BlockCell(
|
||||
self, blockdef, style=style, attribs=attribs, span=span
|
||||
)
|
||||
return self.set_cell(row, col, cell)
|
||||
|
||||
@property
|
||||
def table_width(self) -> float:
|
||||
"""Returns the total table width."""
|
||||
return sum(self.col_widths)
|
||||
|
||||
@property
|
||||
def table_height(self) -> float:
|
||||
"""Returns the total table height."""
|
||||
return sum(self.row_heights)
|
||||
|
||||
def set_cell(self, row: int, col: int, cell: T) -> T:
|
||||
"""Insert a cell at position (row, col)."""
|
||||
row, col = self.validate_index(row, col)
|
||||
self._cells[row, col] = cell
|
||||
return cell
|
||||
|
||||
def get_cell(self, row: int, col: int) -> Cell:
|
||||
"""Get cell at location (row, col)."""
|
||||
row, col = self.validate_index(row, col)
|
||||
try:
|
||||
return self._cells[row, col]
|
||||
except KeyError:
|
||||
return self.empty_cell # empty cell with default style
|
||||
|
||||
def validate_index(self, row: int, col: int) -> tuple[int, int]:
|
||||
row = int(row)
|
||||
col = int(col)
|
||||
if row < 0 or row >= self.nrows or col < 0 or col >= self.ncols:
|
||||
raise IndexError("cell index out of range")
|
||||
return row, col
|
||||
|
||||
def frame(
|
||||
self,
|
||||
row: int,
|
||||
col: int,
|
||||
width: int = 1,
|
||||
height: int = 1,
|
||||
style="default",
|
||||
) -> Frame:
|
||||
"""Creates a frame around the give cell area, starting at (row, col) and
|
||||
covering `width` columns and `height` rows. The `style` argument is the
|
||||
name of a :class:`CellStyle`.
|
||||
"""
|
||||
frame = Frame(self, pos=(row, col), span=(height, width), style=style)
|
||||
self.frames.append(frame)
|
||||
return frame
|
||||
|
||||
def new_cell_style(self, name: str, **kwargs) -> CellStyle:
|
||||
"""Factory method to create a new :class:`CellStyle` object, overwrites
|
||||
an already existing cell style.
|
||||
|
||||
Args:
|
||||
name: style name as string
|
||||
kwargs: see attributes of class :class:`CellStyle`
|
||||
|
||||
"""
|
||||
assert (
|
||||
isinstance(name, str) and name != ""
|
||||
), "name has to be a non-empty string"
|
||||
style: CellStyle = deepcopy(self.get_cell_style("default"))
|
||||
style.update(kwargs)
|
||||
self.styles[name] = style
|
||||
return style
|
||||
|
||||
@staticmethod
|
||||
def new_border_style(
|
||||
color: int = const.BYLAYER,
|
||||
status=True,
|
||||
priority: int = 100,
|
||||
linetype: str = "BYLAYER",
|
||||
lineweight: int = const.LINEWEIGHT_BYLAYER,
|
||||
) -> BorderStyle:
|
||||
"""Factory method to create a new border style.
|
||||
|
||||
Args:
|
||||
status: ``True`` for visible, ``False`` for invisible
|
||||
color: :ref:`ACI`
|
||||
linetype: linetype name, default is "BYLAYER"
|
||||
lineweight: lineweight as int, default is by layer
|
||||
priority: drawing priority, higher priorities cover lower priorities
|
||||
|
||||
"""
|
||||
border_style = BorderStyle()
|
||||
border_style.color = color
|
||||
border_style.linetype = linetype
|
||||
border_style.lineweight = lineweight
|
||||
border_style.status = status
|
||||
border_style.priority = priority
|
||||
return border_style
|
||||
|
||||
def get_cell_style(self, name: str) -> CellStyle:
|
||||
"""Get cell style by name."""
|
||||
return self.styles[name]
|
||||
|
||||
def iter_visible_cells(
|
||||
self, visibility_map: VisibilityMap
|
||||
) -> Iterator[tuple[int, int, Cell]]:
|
||||
"""Iterate over all visible cells"""
|
||||
return (
|
||||
(row, col, self.get_cell(row, col)) for row, col in visibility_map
|
||||
)
|
||||
|
||||
def render(self, layout: GenericLayoutType, insert: Optional[UVec] = None):
|
||||
"""Render table to layout."""
|
||||
insert_backup = self.insert
|
||||
if insert is not None:
|
||||
self.insert = Vec2(insert)
|
||||
visibility_map = VisibilityMap(self)
|
||||
grid = Grid(self)
|
||||
for row, col, cell in self.iter_visible_cells(visibility_map):
|
||||
grid.render_cell_background(layout, row, col, cell)
|
||||
grid.render_cell_content(layout, row, col, cell)
|
||||
grid.render_lines(layout, visibility_map)
|
||||
self.insert = insert_backup
|
||||
|
||||
|
||||
class VisibilityMap:
|
||||
"""Stores the visibility of the table cells."""
|
||||
|
||||
def __init__(self, table: TablePainter):
|
||||
"""Create the visibility map for table."""
|
||||
self.table: TablePainter = table
|
||||
self._hidden_cells: set[tuple[int, int]] = set()
|
||||
self._create_visibility_map()
|
||||
|
||||
def _create_visibility_map(self):
|
||||
"""Set visibility for all existing cells."""
|
||||
for row, col in iter(self):
|
||||
cell = self.table.get_cell(row, col)
|
||||
self._set_span_visibility(row, col, cell.span)
|
||||
|
||||
def _set_span_visibility(self, row: int, col: int, span: tuple[int, int]):
|
||||
"""Set the visibility of the given cell.
|
||||
|
||||
The cell itself is visible, all other cells in the span-range
|
||||
(tuple: width, height) are invisible, they are covered by the
|
||||
main cell (row, col).
|
||||
"""
|
||||
|
||||
if span != (1, 1):
|
||||
nrows, ncols = span
|
||||
for rowx in range(nrows):
|
||||
for colx in range(ncols):
|
||||
# switch all cells in span range to invisible
|
||||
self.hide(row + rowx, col + colx)
|
||||
# switch content cell visible
|
||||
self.show(row, col)
|
||||
|
||||
def show(self, row: int, col: int):
|
||||
"""Show cell (row, col)."""
|
||||
try:
|
||||
self._hidden_cells.remove((row, col))
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def hide(self, row: int, col: int) -> None:
|
||||
"""Hide cell (row, col)."""
|
||||
self._hidden_cells.add((row, col))
|
||||
|
||||
def iter_all_cells(self) -> Iterator[tuple[int, int]]:
|
||||
"""Iterate over all cell indices, yields (row, col) tuples."""
|
||||
for row in range(self.table.nrows):
|
||||
for col in range(self.table.ncols):
|
||||
yield row, col
|
||||
|
||||
def is_visible_cell(self, row: int, col: int) -> bool:
|
||||
"""True if cell (row, col) is visible, else False."""
|
||||
return (row, col) not in self._hidden_cells
|
||||
|
||||
def __iter__(self) -> Iterator[tuple[int, int]]:
|
||||
"""Iterate over all visible cells."""
|
||||
return (
|
||||
(row, col)
|
||||
for (row, col) in self.iter_all_cells()
|
||||
if self.is_visible_cell(row, col)
|
||||
)
|
||||
|
||||
|
||||
class CellStyle:
|
||||
"""Cell style object.
|
||||
|
||||
.. important::
|
||||
|
||||
Always instantiate new styles by the factory method:
|
||||
:meth:`TablePainter.new_cell_style`
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, data: Optional[dict[str, Any]] = None):
|
||||
# text style is ignored by block cells
|
||||
self.text_style = "STANDARD"
|
||||
# text height in drawing units, ignored by block cells
|
||||
self.char_height = DEFAULT_CELL_CHAR_HEIGHT
|
||||
# line spacing in percent = char_height * line_spacing, ignored by block cells
|
||||
self.line_spacing = DEFAULT_CELL_LINE_SPACING
|
||||
# text stretching factor (width factor) or block reference x-scaling factor
|
||||
self.scale_x = DEFAULT_CELL_X_SCALE
|
||||
# block reference y-axis scaling factor, ignored by text cells
|
||||
self.scale_y = DEFAULT_CELL_Y_SCALE
|
||||
# dxf color index, ignored by block cells
|
||||
self.text_color = DEFAULT_CELL_TEXTCOLOR
|
||||
# text or block rotation in degrees
|
||||
self.rotation = 0.0
|
||||
# Letters are stacked top-to-bottom, but not rotated
|
||||
self.stacked = False
|
||||
# text and block alignment, see ezdxf.enums.MTextEntityAlignment
|
||||
self.align = MTextEntityAlignment.TOP_CENTER
|
||||
# left and right cell margin in drawing units
|
||||
self.margin_x = DEFAULT_CELL_X_MARGIN
|
||||
# top and bottom cell margin in drawing units
|
||||
self.margin_y = DEFAULT_CELL_Y_MARGIN
|
||||
# background color, dxf color index, ignored by block cells
|
||||
self.bg_color = DEFAULT_CELL_BG_COLOR
|
||||
# left border style
|
||||
self.left = BorderStyle()
|
||||
# top border style
|
||||
self.top = BorderStyle()
|
||||
# right border style
|
||||
self.right = BorderStyle()
|
||||
# bottom border style
|
||||
self.bottom = BorderStyle()
|
||||
if data:
|
||||
self.update(data)
|
||||
|
||||
def __getitem__(self, k: str) -> Any:
|
||||
return self.__dict__[k]
|
||||
|
||||
def __setitem__(self, k: str, v: Any):
|
||||
if k in self.__dict__:
|
||||
self.__dict__.__setitem__(k, v)
|
||||
else:
|
||||
raise KeyError(f"invalid attribute name: {k}")
|
||||
|
||||
def update(self, data: dict[str, Any]):
|
||||
for k, v in data.items():
|
||||
self.__setitem__(k, v)
|
||||
assert isinstance(
|
||||
self.align, MTextEntityAlignment
|
||||
), "enum ezdxf.enums.MTextEntityAlignment for text alignments required"
|
||||
|
||||
def set_border_status(self, left=True, right=True, top=True, bottom=True):
|
||||
"""Set status of all cell borders at once."""
|
||||
self.left.status = left
|
||||
self.right.status = right
|
||||
self.top.status = top
|
||||
self.bottom.status = bottom
|
||||
|
||||
def set_border_style(
|
||||
self, style: BorderStyle, left=True, right=True, top=True, bottom=True
|
||||
):
|
||||
"""Set border styles of all cell borders at once."""
|
||||
for border, status in (
|
||||
("left", left),
|
||||
("right", right),
|
||||
("top", top),
|
||||
("bottom", bottom),
|
||||
):
|
||||
if status:
|
||||
self[border] = style
|
||||
|
||||
@staticmethod
|
||||
def get_default_border_style() -> BorderStyle:
|
||||
return BorderStyle()
|
||||
|
||||
def get_text_align_flags(self) -> tuple[int, int]:
|
||||
return MAP_MTEXT_ALIGN_TO_FLAGS[self.align]
|
||||
|
||||
|
||||
class BorderStyle:
|
||||
"""Border style class.
|
||||
|
||||
.. important::
|
||||
|
||||
Always instantiate new border styles by the factory method:
|
||||
:meth:`TablePainter.new_border_style`
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status: bool = DEFAULT_BORDER_STATUS,
|
||||
color: int = DEFAULT_BORDER_COLOR,
|
||||
linetype: str = DEFAULT_BORDER_LINETYPE,
|
||||
lineweight=const.LINEWEIGHT_BYLAYER,
|
||||
priority: int = DEFAULT_BORDER_PRIORITY,
|
||||
):
|
||||
# border status, True for visible, False for hidden
|
||||
self.status = status
|
||||
# ACI
|
||||
self.color = color
|
||||
# linetype name, BYLAYER if None
|
||||
self.linetype = linetype
|
||||
# lineweight
|
||||
self.lineweight = lineweight
|
||||
# drawing priority, higher values cover lower values
|
||||
self.priority = priority
|
||||
|
||||
|
||||
class Grid:
|
||||
"""Grid contains the graphical representation of the table."""
|
||||
|
||||
def __init__(self, table: TablePainter):
|
||||
self.table: TablePainter = table
|
||||
# contains the x-axis coords of the grid lines between the data columns.
|
||||
self.col_pos: list[float] = self._calc_col_pos()
|
||||
# contains the y-axis coords of the grid lines between the data rows.
|
||||
self.row_pos: list[float] = self._calc_row_pos()
|
||||
|
||||
# _x_borders contains the horizontal border elements, list of border styles
|
||||
# get index with _border_index(row, col), which means the border element
|
||||
# above row, col, and row-indices are [0 .. nrows+1], nrows+1 for the
|
||||
# grid line below the last row; list contains only the border style with
|
||||
# the highest priority.
|
||||
self._x_borders: list[BorderStyle] = [] # created in _init_borders
|
||||
|
||||
# _y_borders: same as _x_borders but for the vertical borders,
|
||||
# col-indices are [0 .. ncols+1], ncols+1 for the last grid line right
|
||||
# of the last column
|
||||
self._y_borders: list[BorderStyle] = [] # created in _init_borders
|
||||
# border style to delete borders inside of merged cells
|
||||
self.no_border = BorderStyle(
|
||||
status=False, priority=999, linetype="BYLAYER", color=0
|
||||
)
|
||||
|
||||
def _init_borders(self, x_border: BorderStyle, y_border: BorderStyle):
|
||||
"""Init the _hborders with <hborder> and _vborders with <vborder>."""
|
||||
# <border_count> has more elements than necessary, but it unifies the
|
||||
# index calculation for _vborders and _hborders.
|
||||
# exact values are:
|
||||
# x_border_count = ncols * (nrows+1), hindex = ncols * <row> + <col>
|
||||
# y_border_count = nrows * (ncols+1), vindex = (ncols+1) * <row> + <col>
|
||||
border_count: int = (self.table.nrows + 1) * (self.table.ncols + 1)
|
||||
self._x_borders = [x_border] * border_count
|
||||
self._y_borders = [y_border] * border_count
|
||||
|
||||
def _border_index(self, row: int, col: int) -> int:
|
||||
"""Calculate linear index for border arrays _x_borders and _y_borders."""
|
||||
return row * (self.table.ncols + 1) + col
|
||||
|
||||
def set_x_border(self, row: int, col: int, border_style: BorderStyle):
|
||||
"""Set <border_style> for the horizontal border element above
|
||||
<row>, <col>.
|
||||
"""
|
||||
return self._set_border_style(self._x_borders, row, col, border_style)
|
||||
|
||||
def set_y_border(self, row: int, col: int, border_style: BorderStyle):
|
||||
"""Set <border_style> for the vertical border element left of
|
||||
<row>, <col>.
|
||||
"""
|
||||
return self._set_border_style(self._y_borders, row, col, border_style)
|
||||
|
||||
def _set_border_style(
|
||||
self,
|
||||
borders: list[BorderStyle],
|
||||
row: int,
|
||||
col: int,
|
||||
border_style: BorderStyle,
|
||||
):
|
||||
"""Set <border_style> for <row>, <col> in <borders>."""
|
||||
border_index = self._border_index(row, col)
|
||||
actual_borderstyle = borders[border_index]
|
||||
if border_style.priority >= actual_borderstyle.priority:
|
||||
borders[border_index] = border_style
|
||||
|
||||
def get_x_border(self, row: int, col: int) -> BorderStyle:
|
||||
"""Get the horizontal border element above <row>, <col>.
|
||||
Last grid line (below <nrows>) is the element above of <nrows+1>.
|
||||
"""
|
||||
return self._get_border(self._x_borders, row, col)
|
||||
|
||||
def get_y_border(self, row: int, col: int) -> BorderStyle:
|
||||
"""Get the vertical border element left of <row>, <col>.
|
||||
Last grid line (right of <ncols>) is the element left of <ncols+1>.
|
||||
"""
|
||||
return self._get_border(self._y_borders, row, col)
|
||||
|
||||
def _get_border(
|
||||
self, borders: list[BorderStyle], row: int, col: int
|
||||
) -> BorderStyle:
|
||||
"""Get border element at <row>, <col> from <borders>."""
|
||||
return borders[self._border_index(row, col)]
|
||||
|
||||
def _calc_col_pos(self) -> list[float]:
|
||||
"""Calculate the x-axis coords of the grid lines between the columns."""
|
||||
col_pos: list[float] = []
|
||||
start_x: float = self.table.insert.x
|
||||
sum_fields(start_x, self.table.col_widths, col_pos.append)
|
||||
return col_pos
|
||||
|
||||
def _calc_row_pos(self) -> list[float]:
|
||||
"""Calculate the y-axis coords of the grid lines between the rows."""
|
||||
row_pos: list[float] = []
|
||||
start_y: float = self.table.insert.y
|
||||
sum_fields(start_y, self.table.row_heights, row_pos.append, -1.0)
|
||||
return row_pos
|
||||
|
||||
def cell_coords(
|
||||
self, row: int, col: int, span: tuple[int, int]
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Get the coordinates of the cell <row>,<col> as absolute drawing units.
|
||||
|
||||
:return: a tuple (left, right, top, bottom)
|
||||
"""
|
||||
top = self.row_pos[row]
|
||||
bottom = self.row_pos[row + span[0]]
|
||||
left = self.col_pos[col]
|
||||
right = self.col_pos[col + span[1]]
|
||||
return left, right, top, bottom
|
||||
|
||||
def render_cell_background(
|
||||
self, layout: GenericLayoutType, row: int, col: int, cell: Cell
|
||||
):
|
||||
"""Render the cell background for (row, col) as SOLID entity."""
|
||||
style = cell.style
|
||||
if style.bg_color is None:
|
||||
return
|
||||
# get cell coords in absolute drawing units
|
||||
left, right, top, bottom = self.cell_coords(row, col, cell.span)
|
||||
layout.add_solid(
|
||||
points=((left, top), (left, bottom), (right, top), (right, bottom)),
|
||||
dxfattribs={
|
||||
"color": style.bg_color,
|
||||
"layer": self.table.bg_layer_name,
|
||||
},
|
||||
)
|
||||
|
||||
def render_cell_content(
|
||||
self, layout: GenericLayoutType, row: int, col: int, cell: Cell
|
||||
):
|
||||
"""Render the cell content for <row>,<col> into layout object."""
|
||||
# get cell coords in absolute drawing units
|
||||
coords = self.cell_coords(row, col, cell.span)
|
||||
cell.render(layout, coords, self.table.fg_layer_name)
|
||||
|
||||
def render_lines(self, layout: GenericLayoutType, vm: VisibilityMap):
|
||||
"""Render all grid lines into layout object."""
|
||||
# Init borders with default_style top- and left border.
|
||||
default_style = self.table.get_cell_style("default")
|
||||
x_border = default_style.top
|
||||
y_border = default_style.left
|
||||
self._init_borders(x_border, y_border)
|
||||
self._set_frames(self.table.frames)
|
||||
self._set_borders(self.table.iter_visible_cells(vm))
|
||||
self._render_borders(layout, self.table)
|
||||
|
||||
def _set_borders(self, visible_cells: Iterable[tuple[int, int, Cell]]):
|
||||
"""Set borders of the visible cells."""
|
||||
for row, col, cell in visible_cells:
|
||||
bottom_row = row + cell.span[0]
|
||||
right_col = col + cell.span[1]
|
||||
self._set_rect_borders(row, bottom_row, col, right_col, cell.style)
|
||||
self._set_inner_borders(
|
||||
row, bottom_row, col, right_col, self.no_border
|
||||
)
|
||||
|
||||
def _set_inner_borders(
|
||||
self,
|
||||
top_row: int,
|
||||
bottom_row: int,
|
||||
left_col: int,
|
||||
right_col: int,
|
||||
border_style: BorderStyle,
|
||||
):
|
||||
"""Set `border_style` to the inner borders of the rectangle (top_row,
|
||||
bottom_row, ...)
|
||||
"""
|
||||
if bottom_row - top_row > 1:
|
||||
for col in range(left_col, right_col):
|
||||
for row in range(top_row + 1, bottom_row):
|
||||
self.set_x_border(row, col, border_style)
|
||||
if right_col - left_col > 1:
|
||||
for row in range(top_row, bottom_row):
|
||||
for col in range(left_col + 1, right_col):
|
||||
self.set_y_border(row, col, border_style)
|
||||
|
||||
def _set_rect_borders(
|
||||
self,
|
||||
top_row: int,
|
||||
bottom_row: int,
|
||||
left_col: int,
|
||||
right_col: int,
|
||||
style: CellStyle,
|
||||
):
|
||||
"""Set border `style` to the rectangle (top_row, bottom_row, ...)
|
||||
|
||||
The values describing the grid lines between the cells, see doc-strings
|
||||
for methods set_x_border() and set_y_border() and see comments for
|
||||
self._x_borders and self._y_borders.
|
||||
"""
|
||||
for col in range(left_col, right_col):
|
||||
self.set_x_border(top_row, col, style.top)
|
||||
self.set_x_border(bottom_row, col, style.bottom)
|
||||
for row in range(top_row, bottom_row):
|
||||
self.set_y_border(row, left_col, style.left)
|
||||
self.set_y_border(row, right_col, style.right)
|
||||
|
||||
def _set_frames(self, frames: Iterable[Frame]):
|
||||
"""Set borders for all defined frames."""
|
||||
for frame in frames:
|
||||
top_row = frame.pos[0]
|
||||
left_col = frame.pos[1]
|
||||
bottom_row = top_row + frame.span[0]
|
||||
right_col = left_col + frame.span[1]
|
||||
self._set_rect_borders(
|
||||
top_row, bottom_row, left_col, right_col, frame.style
|
||||
)
|
||||
|
||||
def _render_borders(self, layout: GenericLayoutType, table: TablePainter):
|
||||
"""Render the grid lines as LINE entities into layout object."""
|
||||
|
||||
def render_line(start: Vec2, end: Vec2, style: BorderStyle):
|
||||
"""Render the LINE entity into layout object."""
|
||||
if style.status:
|
||||
layout.add_line(
|
||||
start=start,
|
||||
end=end,
|
||||
dxfattribs={
|
||||
"layer": layer,
|
||||
"color": style.color,
|
||||
"linetype": style.linetype,
|
||||
"lineweight": style.lineweight,
|
||||
},
|
||||
)
|
||||
|
||||
def render_x_borders():
|
||||
"""Draw the horizontal grid lines."""
|
||||
for row in range(table.nrows + 1):
|
||||
y = self.row_pos[row]
|
||||
for col in range(table.ncols):
|
||||
left = self.col_pos[col]
|
||||
right = self.col_pos[col + 1]
|
||||
style = self.get_x_border(row, col)
|
||||
render_line(Vec2(left, y), Vec2(right, y), style)
|
||||
|
||||
def render_y_borders():
|
||||
"""Draw the vertical grid lines."""
|
||||
for col in range(table.ncols + 1):
|
||||
x = self.col_pos[col]
|
||||
for row in range(table.nrows):
|
||||
top = self.row_pos[row]
|
||||
bottom = self.row_pos[row + 1]
|
||||
style = self.get_y_border(row, col)
|
||||
render_line(Vec2(x, top), Vec2(x, bottom), style)
|
||||
|
||||
layer = table.grid_layer_name
|
||||
render_x_borders()
|
||||
render_y_borders()
|
||||
|
||||
|
||||
class Frame:
|
||||
"""Represent a rectangle cell area enclosed by borderlines.
|
||||
|
||||
Args:
|
||||
table: the assigned data table
|
||||
pos: tuple (row, col), border goes left and top of pos
|
||||
span: count of cells that Frame covers, border goes right and below of this cells
|
||||
style: style name as string
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table: TablePainter,
|
||||
pos: tuple[int, int] = (0, 0),
|
||||
span: tuple[int, int] = (1, 1),
|
||||
style="default",
|
||||
):
|
||||
self.table = table
|
||||
self.pos = pos
|
||||
self.span = span
|
||||
self.stylename = style
|
||||
|
||||
@property
|
||||
def style(self) -> CellStyle:
|
||||
return self.table.get_cell_style(self.stylename)
|
||||
|
||||
|
||||
class Cell:
|
||||
"""Base class for table cells.
|
||||
|
||||
Args:
|
||||
table: assigned data table
|
||||
style: style name as string
|
||||
span: tuple(spanrows, spancols), count of cells that cell covers
|
||||
|
||||
A cell doesn't know its own position in the data table, because a cell can
|
||||
be used multiple times in the same or in different tables, therefore the
|
||||
cell itself can not determine if the cell-range reaches beyond the table
|
||||
borders.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table: TablePainter,
|
||||
style="default",
|
||||
span: tuple[int, int] = (1, 1),
|
||||
):
|
||||
self.table = table
|
||||
self.stylename = style
|
||||
# span values has to be >= 1
|
||||
self.span = span
|
||||
|
||||
@property
|
||||
def span(self) -> tuple[int, int]:
|
||||
"""Get/set table span parameters."""
|
||||
return self._span
|
||||
|
||||
@span.setter
|
||||
def span(self, value: tuple[int, int]):
|
||||
"""Ensures that span values are >= 1 in each direction."""
|
||||
self._span = (max(1, value[0]), max(1, value[1]))
|
||||
|
||||
@property
|
||||
def style(self) -> CellStyle:
|
||||
"""Returns the associated :class:`CellStyle`."""
|
||||
return self.table.get_cell_style(self.stylename)
|
||||
|
||||
def render(
|
||||
self, layout: GenericLayoutType, coords: Sequence[float], layer: str
|
||||
):
|
||||
"""Renders the cell content into the given `layout`."""
|
||||
pass
|
||||
|
||||
def get_workspace_coords(self, coords: Sequence[float]) -> Sequence[float]:
|
||||
"""Reduces the cell-coords about the margin_x and the margin_y values."""
|
||||
margin_x = self.style.margin_x
|
||||
margin_y = self.style.margin_y
|
||||
return (
|
||||
coords[0] + margin_x, # left
|
||||
coords[1] - margin_x, # right
|
||||
coords[2] - margin_y, # top
|
||||
coords[3] + margin_y, # bottom
|
||||
)
|
||||
|
||||
|
||||
CustomCell = Cell
|
||||
|
||||
|
||||
class TextCell(Cell):
|
||||
"""Implements a cell type containing a multi-line text. Uses the
|
||||
:class:`~ezdxf.addons.MTextSurrogate` add-on to render the multi-line
|
||||
text, therefore the content of these cells is compatible to DXF R12.
|
||||
|
||||
Args:
|
||||
table: assigned data table
|
||||
text: multi line text, lines separated by the new line character ``"\\n"``
|
||||
style: cell style name as string
|
||||
span: tuple(rows, cols) area of cells to cover
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table: TablePainter,
|
||||
text: str,
|
||||
style="default",
|
||||
span: tuple[int, int] = (1, 1),
|
||||
):
|
||||
super(TextCell, self).__init__(table, style, span)
|
||||
self.text = text
|
||||
|
||||
def render(
|
||||
self, layout: GenericLayoutType, coords: Sequence[float], layer: str
|
||||
):
|
||||
"""Text cell.
|
||||
|
||||
Args:
|
||||
layout: target layout
|
||||
coords: tuple of border-coordinates: left, right, top, bottom
|
||||
layer: target layer name as string
|
||||
|
||||
"""
|
||||
if not len(self.text):
|
||||
return
|
||||
|
||||
left, right, top, bottom = self.get_workspace_coords(coords)
|
||||
style = self.style
|
||||
h_align, v_align = style.get_text_align_flags()
|
||||
rotated = self.style.rotation
|
||||
text = self.text
|
||||
if style.stacked:
|
||||
rotated = 0.0
|
||||
text = "\n".join((char for char in self.text.replace("\n", " ")))
|
||||
xpos = (left, float(left + right) / 2.0, right)[h_align]
|
||||
ypos = (bottom, float(bottom + top) / 2.0, top)[v_align - 1]
|
||||
mtext = MTextSurrogate(
|
||||
text,
|
||||
(xpos, ypos),
|
||||
line_spacing=self.style.line_spacing,
|
||||
style=self.style.text_style,
|
||||
char_height=self.style.char_height,
|
||||
rotation=rotated,
|
||||
width_factor=self.style.scale_x,
|
||||
align=style.align,
|
||||
color=self.style.text_color,
|
||||
layer=layer,
|
||||
)
|
||||
mtext.render(layout)
|
||||
|
||||
|
||||
class BlockCell(Cell):
|
||||
"""Implements a cell type containing a block reference.
|
||||
|
||||
Args:
|
||||
table: table object
|
||||
blockdef: :class:`ezdxf.layouts.BlockLayout` instance
|
||||
attribs: BLOCK attributes as (tag, value) dictionary
|
||||
style: cell style name as string
|
||||
span: tuple(rows, cols) area of cells to cover
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table: TablePainter,
|
||||
blockdef: BlockLayout,
|
||||
style="default",
|
||||
attribs=None,
|
||||
span: tuple[int, int] = (1, 1),
|
||||
):
|
||||
if attribs is None:
|
||||
attribs = {}
|
||||
super(BlockCell, self).__init__(table, style, span)
|
||||
self.block_name = blockdef.name # dxf block name!
|
||||
self.attribs = attribs
|
||||
|
||||
def render(
|
||||
self, layout: GenericLayoutType, coords: Sequence[float], layer: str
|
||||
):
|
||||
"""Create the cell content as INSERT-entity with trailing ATTRIB-Entities.
|
||||
|
||||
Args:
|
||||
layout: target layout
|
||||
coords: tuple of border-coordinates : left, right, top, bottom
|
||||
layer: target layer name as string
|
||||
|
||||
"""
|
||||
left, right, top, bottom = self.get_workspace_coords(coords)
|
||||
style = self.style
|
||||
h_align, v_align = style.get_text_align_flags()
|
||||
xpos = (left, float(left + right) / 2.0, right)[h_align]
|
||||
ypos = (bottom, float(bottom + top) / 2.0, top)[v_align - 1]
|
||||
layout.add_auto_blockref(
|
||||
name=self.block_name,
|
||||
insert=(xpos, ypos),
|
||||
values=self.attribs,
|
||||
dxfattribs={
|
||||
"xscale": style.scale_x,
|
||||
"yscale": style.scale_y,
|
||||
"rotation": style.rotation,
|
||||
"layer": layer,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sum_fields(
|
||||
start_value: float,
|
||||
fields: list[float],
|
||||
append: Callable[[float], None],
|
||||
sign: float = 1.0,
|
||||
):
|
||||
"""Adds step-by-step the fields-values, starting with <start_value>,
|
||||
and appends the resulting values to another object with the
|
||||
append-method.
|
||||
"""
|
||||
position = start_value
|
||||
append(position)
|
||||
for element in fields:
|
||||
position += element * sign
|
||||
append(position)
|
||||
@@ -0,0 +1,358 @@
|
||||
# Copyright (c) 2021-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Union
|
||||
import enum
|
||||
|
||||
from ezdxf.entities import Text, Attrib, Hatch, DXFGraphic
|
||||
from ezdxf.lldxf import const
|
||||
from ezdxf.enums import TextEntityAlignment, MAP_TEXT_ENUM_TO_ALIGN_FLAGS
|
||||
from ezdxf.math import Matrix44, BoundingBox
|
||||
from ezdxf import path
|
||||
from ezdxf.path import Path
|
||||
from ezdxf.fonts import fonts
|
||||
from ezdxf.query import EntityQuery
|
||||
|
||||
__all__ = [
|
||||
"make_path_from_str",
|
||||
"make_paths_from_str",
|
||||
"make_hatches_from_str",
|
||||
"make_path_from_entity",
|
||||
"make_paths_from_entity",
|
||||
"make_hatches_from_entity",
|
||||
"virtual_entities",
|
||||
"explode",
|
||||
"Kind",
|
||||
]
|
||||
|
||||
AnyText = Union[Text, Attrib]
|
||||
VALID_TYPES = ("TEXT", "ATTRIB")
|
||||
|
||||
|
||||
def make_path_from_str(
|
||||
s: str,
|
||||
font: fonts.FontFace,
|
||||
size: float = 1.0,
|
||||
align=TextEntityAlignment.LEFT,
|
||||
length: float = 0,
|
||||
m: Matrix44 = None,
|
||||
) -> Path:
|
||||
"""Convert a single line string `s` into a :term:`Multi-Path` object.
|
||||
The text `size` is the height of the uppercase letter "X" (cap height).
|
||||
The paths are aligned about the insertion point at (0, 0).
|
||||
BASELINE means the bottom of the letter "X".
|
||||
|
||||
Args:
|
||||
s: text to convert
|
||||
font: font face definition as :class:`~ezdxf.tools.fonts.FontFace` object
|
||||
size: text size (cap height) in drawing units
|
||||
align: alignment as :class:`ezdxf.enums.TextEntityAlignment`,
|
||||
default is :attr:`LEFT`
|
||||
length: target length for the :attr:`ALIGNED` and :attr:`FIT` alignments
|
||||
m: transformation :class:`~ezdxf.math.Matrix44`
|
||||
|
||||
"""
|
||||
if len(s) == 0:
|
||||
return Path()
|
||||
abstract_font = get_font(font)
|
||||
# scale font rendering units to drawing units:
|
||||
p = _str_to_path(s, abstract_font, size)
|
||||
bbox = path.bbox([p], fast=True)
|
||||
|
||||
# Text is rendered in drawing units,
|
||||
# therefore do alignment in drawing units:
|
||||
draw_units_fm = abstract_font.measurements.scale_from_baseline(size)
|
||||
matrix = alignment_transformation(draw_units_fm, bbox, align, length)
|
||||
if m is not None:
|
||||
matrix *= m
|
||||
return p.transform(matrix)
|
||||
|
||||
|
||||
def make_paths_from_str(
|
||||
s: str,
|
||||
font: fonts.FontFace,
|
||||
size: float = 1.0,
|
||||
align=TextEntityAlignment.LEFT,
|
||||
length: float = 0,
|
||||
m: Matrix44 = None,
|
||||
) -> list[Path]:
|
||||
"""Convert a single line string `s` into a list of
|
||||
:class:`~ezdxf.path.Path` objects. All paths are returned as a list of
|
||||
:term:`Single-Path` objects.
|
||||
The text `size` is the height of the uppercase letter "X" (cap height).
|
||||
The paths are aligned about the insertion point at (0, 0).
|
||||
BASELINE means the bottom of the letter "X".
|
||||
|
||||
Args:
|
||||
s: text to convert
|
||||
font: font face definition as :class:`~ezdxf.tools.fonts.FontFace` object
|
||||
size: text size (cap height) in drawing units
|
||||
align: alignment as :class:`ezdxf.enums.TextEntityAlignment`,
|
||||
default is :attr:`LEFT`
|
||||
length: target length for the :attr:`ALIGNED` and :attr:`FIT` alignments
|
||||
m: transformation :class:`~ezdxf.math.Matrix44`
|
||||
|
||||
"""
|
||||
if len(s) == 0:
|
||||
return []
|
||||
p = make_path_from_str(s, font, size, align, length, m)
|
||||
return list(p.sub_paths())
|
||||
|
||||
|
||||
def get_font(font: fonts.FontFace) -> fonts.AbstractFont:
|
||||
font_name = fonts.font_manager.find_font_name(font)
|
||||
return fonts.make_font(font_name, cap_height=1.0)
|
||||
|
||||
|
||||
def _str_to_path(s: str, render_engine: fonts.AbstractFont, size: float = 1.0) -> Path:
|
||||
return render_engine.text_path_ex(s, cap_height=size).to_path()
|
||||
|
||||
|
||||
def alignment_transformation(
|
||||
fm: fonts.FontMeasurements,
|
||||
bbox: BoundingBox,
|
||||
align: TextEntityAlignment,
|
||||
length: float,
|
||||
) -> Matrix44:
|
||||
"""Returns the alignment transformation matrix to transform a basic
|
||||
text path at location (0, 0) and alignment :attr:`LEFT` into the final text
|
||||
path of the given alignment.
|
||||
For the alignments :attr:`FIT` and :attr:`ALIGNED` defines the argument
|
||||
`length` the total length of the final text path. The given bounding box
|
||||
defines the rendering borders of the basic text path.
|
||||
|
||||
"""
|
||||
halign, valign = MAP_TEXT_ENUM_TO_ALIGN_FLAGS[align]
|
||||
matrix = basic_alignment_transformation(fm, bbox, halign, valign)
|
||||
|
||||
stretch_x = 1.0
|
||||
stretch_y = 1.0
|
||||
if align == TextEntityAlignment.ALIGNED:
|
||||
stretch_x = length / bbox.size.x
|
||||
stretch_y = stretch_x
|
||||
elif align == TextEntityAlignment.FIT:
|
||||
stretch_x = length / bbox.size.x
|
||||
if stretch_x != 1.0:
|
||||
matrix *= Matrix44.scale(stretch_x, stretch_y, 1.0)
|
||||
return matrix
|
||||
|
||||
|
||||
def basic_alignment_transformation(
|
||||
fm: fonts.FontMeasurements, bbox: BoundingBox, halign: int, valign: int
|
||||
) -> Matrix44:
|
||||
if halign == const.LEFT:
|
||||
shift_x = 0.0
|
||||
elif halign == const.RIGHT:
|
||||
assert bbox.extmax is not None, "invalid empty bounding box"
|
||||
shift_x = -bbox.extmax.x
|
||||
elif halign == const.CENTER or halign > 2: # ALIGNED, MIDDLE, FIT
|
||||
assert bbox.center is not None, "invalid empty bounding box"
|
||||
shift_x = -bbox.center.x
|
||||
else:
|
||||
raise ValueError(f"invalid halign argument: {halign}")
|
||||
cap_height = fm.cap_height
|
||||
descender_height = fm.descender_height
|
||||
if valign == const.BASELINE:
|
||||
shift_y = 0.0
|
||||
elif valign == const.TOP:
|
||||
shift_y = -cap_height
|
||||
elif valign == const.MIDDLE:
|
||||
shift_y = -cap_height / 2
|
||||
elif valign == const.BOTTOM:
|
||||
shift_y = descender_height
|
||||
else:
|
||||
raise ValueError(f"invalid valign argument: {valign}")
|
||||
if halign == 4: # MIDDLE
|
||||
shift_y = -cap_height + fm.total_height / 2.0
|
||||
return Matrix44.translate(shift_x, shift_y, 0)
|
||||
|
||||
|
||||
def make_hatches_from_str(
|
||||
s: str,
|
||||
font: fonts.FontFace,
|
||||
size: float = 1.0,
|
||||
align=TextEntityAlignment.LEFT,
|
||||
length: float = 0,
|
||||
dxfattribs=None,
|
||||
m: Matrix44 = None,
|
||||
) -> list[Hatch]:
|
||||
"""Convert a single line string `s` into a list of virtual
|
||||
:class:`~ezdxf.entities.Hatch` entities.
|
||||
The text `size` is the height of the uppercase letter "X" (cap height).
|
||||
The paths are aligned about the insertion point at (0, 0).
|
||||
The HATCH entities are aligned to this insertion point. BASELINE means the
|
||||
bottom of the letter "X".
|
||||
|
||||
.. important::
|
||||
|
||||
Returns an empty list for .shx, .shp and .lff fonts a.k.a. stroke fonts.
|
||||
|
||||
Args:
|
||||
s: text to convert
|
||||
font: font face definition as :class:`~ezdxf.tools.fonts.FontFace` object
|
||||
size: text size (cap height) in drawing units
|
||||
align: alignment as :class:`ezdxf.enums.TextEntityAlignment`,
|
||||
default is :attr:`LEFT`
|
||||
length: target length for the :attr:`ALIGNED` and :attr:`FIT` alignments
|
||||
dxfattribs: additional DXF attributes
|
||||
m: transformation :class:`~ezdxf.math.Matrix44`
|
||||
|
||||
"""
|
||||
font_ = get_font(font)
|
||||
if font_.font_render_type is fonts.FontRenderType.STROKE:
|
||||
return []
|
||||
|
||||
# HATCH is an OCS entity, transforming just the polyline paths
|
||||
# is not correct! The Hatch has to be created in the xy-plane!
|
||||
paths = make_paths_from_str(s, font, size, align, length)
|
||||
dxfattribs = dict(dxfattribs or {})
|
||||
dxfattribs.setdefault("solid_fill", 1)
|
||||
dxfattribs.setdefault("pattern_name", "SOLID")
|
||||
dxfattribs.setdefault("color", const.BYLAYER)
|
||||
hatches = path.to_hatches(paths, edge_path=True, dxfattribs=dxfattribs)
|
||||
if m is not None:
|
||||
# Transform HATCH entities as a unit:
|
||||
return [hatch.transform(m) for hatch in hatches] # type: ignore
|
||||
else:
|
||||
return list(hatches)
|
||||
|
||||
|
||||
def check_entity_type(entity):
|
||||
if entity is None:
|
||||
raise TypeError("entity is None")
|
||||
elif not entity.dxftype() in VALID_TYPES:
|
||||
raise TypeError(f"unsupported entity type: {entity.dxftype()}")
|
||||
|
||||
|
||||
def make_path_from_entity(entity: AnyText) -> Path:
|
||||
"""Convert text content from DXF entities TEXT and ATTRIB into a
|
||||
:term:`Multi-Path` object.
|
||||
The paths are located at the location of the source entity.
|
||||
"""
|
||||
|
||||
check_entity_type(entity)
|
||||
text = entity.plain_text()
|
||||
p = make_path_from_str(
|
||||
text,
|
||||
fonts.get_font_face(entity.font_name()),
|
||||
size=entity.dxf.height, # cap height in drawing units
|
||||
align=entity.get_align_enum(),
|
||||
length=entity.fit_length(),
|
||||
)
|
||||
m = entity.wcs_transformation_matrix()
|
||||
return p.transform(m)
|
||||
|
||||
|
||||
def make_paths_from_entity(entity: AnyText) -> list[Path]:
|
||||
"""Convert text content from DXF entities TEXT and ATTRIB into a
|
||||
list of :class:`~ezdxf.path.Path` objects. All paths are returned as a
|
||||
list of :term:`Single-Path` objects.
|
||||
The paths are located at the location of the source entity.
|
||||
|
||||
"""
|
||||
return list(make_path_from_entity(entity).sub_paths())
|
||||
|
||||
|
||||
def make_hatches_from_entity(entity: AnyText) -> list[Hatch]:
|
||||
"""Convert text content from DXF entities TEXT and ATTRIB into a
|
||||
list of virtual :class:`~ezdxf.entities.Hatch` entities.
|
||||
The hatches are placed at the same location as the source entity and have
|
||||
the same DXF attributes as the source entity.
|
||||
|
||||
"""
|
||||
check_entity_type(entity)
|
||||
extrusion = entity.dxf.extrusion
|
||||
attribs = entity.graphic_properties()
|
||||
paths = make_paths_from_entity(entity)
|
||||
return list(
|
||||
path.to_hatches(
|
||||
paths,
|
||||
edge_path=True,
|
||||
extrusion=extrusion,
|
||||
dxfattribs=attribs,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@enum.unique
|
||||
class Kind(enum.IntEnum):
|
||||
"""The :class:`Kind` enum defines the DXF types to create as bit flags,
|
||||
e.g. 1+2 to get HATCHES as filling and SPLINES and POLYLINES as outline:
|
||||
|
||||
=== =========== ==============================
|
||||
Int Enum Description
|
||||
=== =========== ==============================
|
||||
1 HATCHES :class:`~ezdxf.entities.Hatch` entities as filling
|
||||
2 SPLINES :class:`~ezdxf.entities.Spline` and 3D :class:`~ezdxf.entities.Polyline`
|
||||
entities as outline
|
||||
4 LWPOLYLINES :class:`~ezdxf.entities.LWPolyline` entities as approximated
|
||||
(flattened) outline
|
||||
=== =========== ==============================
|
||||
|
||||
"""
|
||||
|
||||
HATCHES = 1
|
||||
SPLINES = 2
|
||||
LWPOLYLINES = 4
|
||||
|
||||
|
||||
def virtual_entities(entity: AnyText, kind: int = Kind.HATCHES) -> EntityQuery:
|
||||
"""Convert the text content of DXF entities TEXT and ATTRIB into virtual
|
||||
SPLINE and 3D POLYLINE entities or approximated LWPOLYLINE entities
|
||||
as outlines, or as HATCH entities as fillings.
|
||||
|
||||
Returns the virtual DXF entities as an :class:`~ezdxf.query.EntityQuery`
|
||||
object.
|
||||
|
||||
Args:
|
||||
entity: TEXT or ATTRIB entity
|
||||
kind: kind of entities to create as bit flags, see enum :class:`Kind`
|
||||
|
||||
"""
|
||||
check_entity_type(entity)
|
||||
extrusion = entity.dxf.extrusion
|
||||
attribs = entity.graphic_properties()
|
||||
entities: list[DXFGraphic] = []
|
||||
|
||||
if kind & Kind.HATCHES:
|
||||
entities.extend(make_hatches_from_entity(entity))
|
||||
if kind & (Kind.SPLINES + Kind.LWPOLYLINES):
|
||||
paths = make_paths_from_entity(entity)
|
||||
if kind & Kind.SPLINES:
|
||||
entities.extend(path.to_splines_and_polylines(paths, dxfattribs=attribs))
|
||||
if kind & Kind.LWPOLYLINES:
|
||||
entities.extend(
|
||||
path.to_lwpolylines(paths, extrusion=extrusion, dxfattribs=attribs)
|
||||
)
|
||||
|
||||
return EntityQuery(entities)
|
||||
|
||||
|
||||
def explode(entity: AnyText, kind: int = Kind.HATCHES, target=None) -> EntityQuery:
|
||||
"""Explode the text `entity` into virtual entities,
|
||||
see :func:`virtual_entities`. The source entity will be destroyed.
|
||||
|
||||
The target layout is given by the `target` argument, if `target` is ``None``,
|
||||
the target layout is the source layout of the text entity.
|
||||
|
||||
Returns the created DXF entities as an :class:`~ezdxf.query.EntityQuery`
|
||||
object.
|
||||
|
||||
Args:
|
||||
entity: TEXT or ATTRIB entity to explode
|
||||
kind: kind of entities to create as bit flags, see enum :class:`Kind`
|
||||
target: target layout for new created DXF entities, ``None`` for the
|
||||
same layout as the source entity.
|
||||
|
||||
"""
|
||||
entities = virtual_entities(entity, kind)
|
||||
|
||||
# Explicit check for None is required, because empty layouts are also False
|
||||
if target is None:
|
||||
target = entity.get_layout()
|
||||
entity.destroy()
|
||||
|
||||
if target is not None:
|
||||
for e in entities:
|
||||
target.add_entity(e)
|
||||
return EntityQuery(entities)
|
||||
@@ -0,0 +1,90 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
"""xplayer = cross backend player."""
|
||||
from __future__ import annotations
|
||||
from typing import Callable
|
||||
from ezdxf.math import Vec2
|
||||
from ezdxf.colors import RGB
|
||||
|
||||
from ezdxf.addons.drawing.backend import BackendInterface, BkPath2d
|
||||
from ezdxf.addons.drawing.properties import BackendProperties
|
||||
from ezdxf.addons.hpgl2 import api as hpgl2
|
||||
from ezdxf.addons.hpgl2.backend import (
|
||||
Properties as HPGL2Properties,
|
||||
RecordType as HPGL2RecordType,
|
||||
)
|
||||
|
||||
|
||||
def hpgl2_to_drawing(
|
||||
player: hpgl2.Player,
|
||||
backend: BackendInterface,
|
||||
bg_color: str = "#ffffff",
|
||||
override: Callable[[BackendProperties], BackendProperties] | None = None,
|
||||
) -> None:
|
||||
"""Replays the recordings of the HPGL2 Recorder on a backend of the drawing add-on."""
|
||||
if bg_color:
|
||||
backend.set_background(bg_color)
|
||||
for record_type, properties, record_data in player.recordings():
|
||||
backend_properties = _make_drawing_backend_properties(properties)
|
||||
if override:
|
||||
backend_properties = override(backend_properties)
|
||||
if record_type == HPGL2RecordType.POLYLINE:
|
||||
points: list[Vec2] = record_data.vertices()
|
||||
size = len(points)
|
||||
if size == 1:
|
||||
backend.draw_point(points[0], backend_properties)
|
||||
elif size == 2:
|
||||
backend.draw_line(points[0], points[1], backend_properties)
|
||||
else:
|
||||
backend.draw_path(BkPath2d.from_vertices(points), backend_properties)
|
||||
elif record_type == HPGL2RecordType.FILLED_PATHS:
|
||||
backend.draw_filled_paths(record_data, backend_properties)
|
||||
elif record_type == HPGL2RecordType.OUTLINE_PATHS:
|
||||
for p in record_data:
|
||||
backend.draw_path(p, backend_properties)
|
||||
backend.finalize()
|
||||
|
||||
|
||||
def _make_drawing_backend_properties(properties: HPGL2Properties) -> BackendProperties:
|
||||
"""Make BackendProperties() for the drawing add-on."""
|
||||
return BackendProperties(
|
||||
color=properties.pen_color.to_hex(),
|
||||
lineweight=properties.pen_width,
|
||||
layer="0",
|
||||
pen=properties.pen_index,
|
||||
handle="",
|
||||
)
|
||||
|
||||
|
||||
def map_color(color: str) -> Callable[[BackendProperties], BackendProperties]:
|
||||
def _map_color(properties: BackendProperties) -> BackendProperties:
|
||||
return BackendProperties(
|
||||
color=color,
|
||||
lineweight=properties.lineweight,
|
||||
layer=properties.layer,
|
||||
pen=properties.pen,
|
||||
handle=properties.handle,
|
||||
)
|
||||
|
||||
return _map_color
|
||||
|
||||
|
||||
def map_monochrome(dark_mode=True) -> Callable[[BackendProperties], BackendProperties]:
|
||||
def to_gray(color: str) -> str:
|
||||
gray = round(RGB.from_hex(color).luminance * 255)
|
||||
if dark_mode:
|
||||
gray = 255 - gray
|
||||
return RGB(gray, gray, gray).to_hex()
|
||||
|
||||
def _map_color(properties: BackendProperties) -> BackendProperties:
|
||||
color = properties.color
|
||||
alpha = color[7:9]
|
||||
return BackendProperties(
|
||||
color=to_gray(color[:7]) + alpha,
|
||||
lineweight=properties.lineweight,
|
||||
layer=properties.layer,
|
||||
pen=properties.pen,
|
||||
handle=properties.handle,
|
||||
)
|
||||
|
||||
return _map_color
|
||||
@@ -0,0 +1,86 @@
|
||||
# Copyright (c) 2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# mypy: ignore_errors=True
|
||||
from ezdxf._options import DRAWING_ADDON, options
|
||||
|
||||
# Qt compatibility layer: all Qt imports from ezdxf.addons.xqt
|
||||
PYSIDE6 = False
|
||||
TRY_PYSIDE6 = options.get_bool(DRAWING_ADDON, "try_pyside6", True)
|
||||
PYQT5 = False
|
||||
TRY_PYQT5 = options.get_bool(DRAWING_ADDON, "try_pyqt5", True)
|
||||
|
||||
if TRY_PYSIDE6:
|
||||
try:
|
||||
from PySide6 import QtGui, QtCore, QtWidgets
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
QTableView,
|
||||
QTreeView,
|
||||
QListView,
|
||||
)
|
||||
from PySide6.QtCore import (
|
||||
QAbstractTableModel,
|
||||
QStringListModel,
|
||||
QFileSystemWatcher,
|
||||
QModelIndex,
|
||||
QPointF,
|
||||
QSettings,
|
||||
QSize,
|
||||
Qt,
|
||||
Signal,
|
||||
Slot,
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QColor,
|
||||
QPainterPath,
|
||||
QStandardItem,
|
||||
QStandardItemModel,
|
||||
)
|
||||
|
||||
PYSIDE6 = True
|
||||
print("using Qt binding: PySide6")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# PyQt5 is just a fallback
|
||||
if TRY_PYQT5 and not PYSIDE6:
|
||||
try:
|
||||
from PyQt5 import QtGui, QtCore, QtWidgets
|
||||
from PyQt5.QtCore import pyqtSignal as Signal
|
||||
from PyQt5.QtCore import pyqtSlot as Slot
|
||||
from PyQt5.QtWidgets import (
|
||||
QAction,
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
QTableView,
|
||||
QTreeView,
|
||||
QListView,
|
||||
)
|
||||
from PyQt5.QtCore import (
|
||||
QAbstractTableModel,
|
||||
QStringListModel,
|
||||
QFileSystemWatcher,
|
||||
QModelIndex,
|
||||
QPointF,
|
||||
QSettings,
|
||||
QSize,
|
||||
Qt,
|
||||
)
|
||||
from PyQt5.QtGui import (
|
||||
QColor,
|
||||
QPainterPath,
|
||||
QStandardItem,
|
||||
QStandardItemModel,
|
||||
)
|
||||
|
||||
PYQT5 = True
|
||||
print("using Qt binding: PyQt5")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not (PYSIDE6 or PYQT5):
|
||||
raise ImportError("no Qt binding found, tried PySide6 and PyQt5")
|
||||
Reference in New Issue
Block a user