Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/matplotlib.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

362 lines
12 KiB
Python

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