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

620 lines
20 KiB
Python

# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Optional, Iterator, Sequence
from typing_extensions import Self, TypeAlias
import abc
import numpy as np
import numpy.typing as npt
from ezdxf.math import (
Matrix44,
UVec,
Vec2,
Vec3,
has_clockwise_orientation,
Bezier3P,
Bezier4P,
BoundingBox2d,
BoundingBox,
)
from ezdxf.path import (
Path,
Command,
PathElement,
LineTo,
MoveTo,
Curve3To,
Curve4To,
nesting,
)
try:
from ezdxf.acc import np_support
except ImportError:
np_support = None
__all__ = [
"NumpyPath2d",
"NumpyPoints2d",
"NumpyPoints3d",
"NumpyShapesException",
"EmptyShapeError",
"to_qpainter_path",
"to_matplotlib_path",
"single_paths",
"orient_paths",
]
# 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 NumpyShapesException(Exception):
pass
class EmptyShapeError(NumpyShapesException):
pass
CommandNumpyType: TypeAlias = np.int8
VertexNumpyType: TypeAlias = np.float64
EMPTY_SHAPE = np.array([], dtype=VertexNumpyType)
NO_COMMANDS = np.array([], dtype=CommandNumpyType)
class NumpyShape2d(abc.ABC):
"""This is an optimization to store many 2D paths and polylines in a compact way
without sacrificing basic functions like transformation and bounding box calculation.
"""
_vertices: npt.NDArray[VertexNumpyType] = EMPTY_SHAPE
def extents(self) -> tuple[Vec2, Vec2]:
"""Returns the extents of the bounding box as tuple (extmin, extmax)."""
v = self._vertices
if len(v) > 0:
return Vec2(v.min(0)), Vec2(v.max(0))
else:
raise EmptyShapeError("empty shape has no extends")
@abc.abstractmethod
def clone(self) -> Self:
...
def np_vertices(self) -> npt.NDArray[VertexNumpyType]:
return self._vertices
def transform_inplace(self, m: Matrix44) -> None:
"""Transforms the vertices of the shape inplace."""
v = self._vertices
if len(v) == 0:
return
m.transform_array_inplace(v, 2)
def vertices(self) -> list[Vec2]:
"""Returns the shape vertices as list of :class:`Vec2`
e.g. [Vec2(1, 2), Vec2(3, 4), ...]
"""
return [Vec2(v) for v in self._vertices]
def to_tuples(self) -> list[tuple[float, float]]:
"""Returns the shape vertices as list of 2-tuples
e.g. [(1, 2), (3, 4), ...]
"""
return [tuple(v) for v in self._vertices]
def to_list(self) -> list[list[float]]:
"""Returns the shape vertices as list of lists
e.g. [[1, 2], [3, 4], ...]
"""
return self._vertices.tolist()
def bbox(self) -> BoundingBox2d:
"""Returns the bounding box of all vertices."""
return BoundingBox2d(self.extents())
class NumpyPoints2d(NumpyShape2d):
"""Represents an array of 2D points stored as a ndarray."""
def __init__(self, points: Optional[Iterable[Vec2 | Vec3]]) -> None:
if points:
self._vertices = np.array(
[(v.x, v.y) for v in points], dtype=VertexNumpyType
)
def clone(self) -> Self:
clone = self.__class__(None)
clone._vertices = self._vertices.copy()
return clone
__copy__ = clone
def __len__(self) -> int:
return len(self._vertices)
class NumpyPath2d(NumpyShape2d):
"""Represents a 2D path, the path control vertices and commands are stored as ndarray.
This class cannot build paths from scratch and is therefore not a drop-in replacement
for the :class:`ezdxf.path.Path` class. Operations like transform and reverse are
done inplace to utilize the `numpy` capabilities. This behavior is different from the
:class:`ezdxf.path.Path` class!!!
Construct new paths by the :class:`Path` class and convert them to
:class:`NumpyPath2d` instances::
path = Path((0, 0))
path.line_to((50, 70))
...
path2d = NumpyPath2d(path)
"""
_commands: npt.NDArray[CommandNumpyType]
def __init__(self, path: Optional[Path]) -> None:
if path is None:
self._vertices = EMPTY_SHAPE
self._commands = NO_COMMANDS
return
# (v.x, v.y) is 4x faster than Vec2(v), see profiling/numpy_array_setup.py
vertices = [(v.x, v.y) for v in path.control_vertices()]
if len(vertices) == 0:
try: # control_vertices() does not return the start point of empty paths
vertices = [Vec2(path.start)]
except IndexError:
vertices = []
self._vertices = np.array(vertices, dtype=VertexNumpyType)
self._commands = np.array(path.command_codes(), dtype=CommandNumpyType)
def __len__(self) -> int:
return len(self._commands)
@property
def start(self) -> Vec2:
"""Returns the start point as :class:`~ezdxf.math.Vec2` instance."""
return Vec2(self._vertices[0])
@property
def end(self) -> Vec2:
"""Returns the end point as :class:`~ezdxf.math.Vec2` instance."""
return Vec2(self._vertices[-1])
def control_vertices(self) -> list[Vec2]:
return [Vec2(v) for v in self._vertices]
def clone(self) -> Self:
clone = self.__class__(None)
clone._commands = self._commands.copy()
clone._vertices = self._vertices.copy()
return clone
__copy__ = clone
def command_codes(self) -> list[int]:
"""Internal API."""
return list(self._commands)
def commands(self) -> Iterator[PathElement]:
vertices = self.vertices()
index = 1
for cmd in self._commands:
if cmd == CMD_LINE_TO:
yield LineTo(vertices[index])
index += 1
elif cmd == CMD_CURVE3_TO:
yield Curve3To(vertices[index + 1], vertices[index])
index += 2
elif cmd == CMD_CURVE4_TO:
yield Curve4To(
vertices[index + 2], vertices[index], vertices[index + 1]
)
index += 3
elif cmd == CMD_MOVE_TO:
yield MoveTo(vertices[index])
index += 1
def to_path(self) -> Path:
"""Returns a new :class:`ezdxf.path.Path` instance."""
vertices = [Vec3(v) for v in self._vertices]
commands = [Command(c) for c in self._commands]
return Path.from_vertices_and_commands(vertices, commands)
@classmethod
def from_vertices(
cls, vertices: Iterable[Vec2 | Vec3], close: bool = False
) -> Self:
new_path = cls(None)
vertices = list(vertices)
if len(vertices) == 0:
return new_path
if close and not vertices[0].isclose(vertices[-1]):
vertices.append(vertices[0])
# (v.x, v.y) is 4x faster than Vec2(v), see profiling/numpy_array_setup.py
points = [(v.x, v.y) for v in vertices]
new_path._vertices = np.array(points, dtype=VertexNumpyType)
new_path._commands = np.full(
len(points) - 1, fill_value=CMD_LINE_TO, dtype=CommandNumpyType
)
return new_path
@property
def has_sub_paths(self) -> bool:
"""Returns ``True`` if the path is a :term:`Multi-Path` object that
contains multiple sub-paths.
"""
return CMD_MOVE_TO in self._commands
@property
def is_closed(self) -> bool:
"""Returns ``True`` if the start point is close to the end point."""
if len(self._vertices) > 1:
return self.start.isclose(self.end)
return False
@property
def has_lines(self) -> bool:
"""Returns ``True`` if the path has any line segments."""
return CMD_LINE_TO in self._commands
@property
def has_curves(self) -> bool:
"""Returns ``True`` if the path has any curve segments."""
return CMD_CURVE3_TO in self._commands or CMD_CURVE4_TO in self._commands
def sub_paths(self) -> list[Self]:
"""Yield all sub-paths as :term:`Single-Path` objects.
It's safe to call :meth:`sub_paths` on any path-type:
:term:`Single-Path`, :term:`Multi-Path` and :term:`Empty-Path`.
"""
def append_sub_path() -> None:
s: Self = self.__class__(None)
s._vertices = vertices[vtx_start_index : vtx_index + 1] # .copy() ?
s._commands = commands[cmd_start_index:cmd_index] # .copy() ?
sub_paths.append(s)
commands = self._commands
if len(commands) == 0:
return []
if CMD_MOVE_TO not in commands:
return [self]
sub_paths: list[Self] = []
vertices = self._vertices
vtx_start_index = 0
vtx_index = 0
cmd_start_index = 0
cmd_index = 0
for cmd in commands:
if cmd == CMD_LINE_TO:
vtx_index += 1
elif cmd == CMD_CURVE3_TO:
vtx_index += 2
elif cmd == CMD_CURVE4_TO:
vtx_index += 3
elif cmd == CMD_MOVE_TO:
append_sub_path()
# MOVE_TO target vertex is the start vertex of the following path.
vtx_index += 1
vtx_start_index = vtx_index
cmd_start_index = cmd_index + 1
cmd_index += 1
if commands[-1] != CMD_MOVE_TO:
append_sub_path()
return sub_paths
def has_clockwise_orientation(self) -> bool:
"""Returns ``True`` if 2D path has clockwise orientation.
Raises:
TypeError: can't detect orientation of a :term:`Multi-Path` object
"""
if self.has_sub_paths:
raise TypeError("can't detect orientation of a multi-path object")
if np_support is None:
return has_clockwise_orientation(self.vertices())
else:
return np_support.has_clockwise_orientation(self._vertices)
def reverse(self) -> Self:
"""Reverse path orientation inplace."""
commands = self._commands
if not len(self._commands):
return self
if commands[-1] == CMD_MOVE_TO:
# The last move_to will become the first move_to.
# A move_to as first command just moves the start point and can be
# removed!
# There are never two consecutive MOVE_TO commands in a Path!
self._commands = np.flip(commands[:-1]).copy()
self._vertices = np.flip(self._vertices[:-1, ...], axis=0).copy()
else:
self._commands = np.flip(commands).copy()
self._vertices = np.flip(self._vertices, axis=0).copy()
return self
def clockwise(self) -> Self:
"""Apply clockwise orientation inplace.
Raises:
TypeError: can't detect orientation of a :term:`Multi-Path` object
"""
if not self.has_clockwise_orientation():
self.reverse()
return self
def counter_clockwise(self) -> Self:
"""Apply counter-clockwise orientation inplace.
Raises:
TypeError: can't detect orientation of a :term:`Multi-Path` object
"""
if self.has_clockwise_orientation():
self.reverse()
return self
def flattening(self, distance: float, segments: int = 4) -> Iterator[Vec2]:
"""Flatten path to vertices as :class:`Vec2` instances."""
if not len(self._commands):
return
vertices = self.vertices()
start = vertices[0]
yield start
index = 1
for cmd in self._commands:
if cmd == CMD_LINE_TO or cmd == CMD_MOVE_TO:
end_location = vertices[index]
index += 1
yield end_location
elif cmd == CMD_CURVE3_TO:
ctrl, end_location = vertices[index : index + 2]
index += 2
pts = Vec2.generate(
Bezier3P((start, ctrl, end_location)).flattening(distance, segments)
)
next(pts) # skip first vertex
yield from pts
elif cmd == CMD_CURVE4_TO:
ctrl1, ctrl2, end_location = vertices[index : index + 3]
index += 3
pts = Vec2.generate(
Bezier4P((start, ctrl1, ctrl2, end_location)).flattening(
distance, segments
)
)
next(pts) # skip first vertex
yield from pts
else:
raise ValueError(f"Invalid command: {cmd}")
start = end_location
# Appending single commands (line_to, move_to, curve3_to, curve4_to) is not
# efficient, because numpy arrays do not grow dynamically, they are reallocated for
# every single command!
# Construct paths as ezdxf.path.Path and convert them to NumpyPath2d.
# Concatenation of NumpyPath2d objects is faster than extending Path objects
def extend(self, paths: Sequence[NumpyPath2d]) -> None:
"""Extend an existing path by appending additional paths. The paths are
connected by MOVE_TO commands if the end- and start point of sequential paths
are not coincident (multi-path).
"""
if not len(paths):
return
if not len(self._commands):
first = paths[0]
paths = paths[1:]
else:
first = self
vertices: list[np.ndarray] = [first._vertices]
commands: list[np.ndarray] = [first._commands]
end: Vec2 = first.end
for next_path in paths:
if len(next_path._commands) == 0:
continue
if not end.isclose(next_path.start):
commands.append(np.array((CMD_MOVE_TO,), dtype=CommandNumpyType))
vertices.append(next_path._vertices)
else:
vertices.append(next_path._vertices[1:])
end = next_path.end
commands.append(next_path._commands)
self._vertices = np.concatenate(vertices, axis=0)
self._commands = np.concatenate(commands)
@staticmethod
def concatenate(paths: Sequence[NumpyPath2d]) -> NumpyPath2d:
"""Returns a new path of concatenated paths. The paths are connected by
MOVE_TO commands if the end- and start point of sequential paths are not
coincident (multi-path).
"""
if not paths:
return NumpyPath2d(None)
first = paths[0].clone()
first.extend(paths[1:])
return first
def to_qpainter_path(paths: Iterable[NumpyPath2d]):
"""Convert the given `paths` into a single :class:`QPainterPath`."""
from ezdxf.addons.xqt import QPainterPath, QPointF
paths = list(paths)
if len(paths) == 0:
raise ValueError("one or more paths required")
qpath = QPainterPath()
for path in paths:
points = [QPointF(v.x, v.y) for v in path.vertices()]
qpath.moveTo(points[0])
index = 1
for cmd in path.command_codes():
# using Command.<attr> slows down this function by a factor of 4!!!
if cmd == CMD_LINE_TO:
qpath.lineTo(points[index])
index += 1
elif cmd == CMD_CURVE3_TO:
qpath.quadTo(points[index], points[index + 1])
index += 2
elif cmd == CMD_CURVE4_TO:
qpath.cubicTo(points[index], points[index + 1], points[index + 2])
index += 3
elif cmd == CMD_MOVE_TO:
qpath.moveTo(points[index])
index += 1
return qpath
MPL_MOVETO = 1
MPL_LINETO = 2
MPL_CURVE3 = 3
MPL_CURVE4 = 4
MPL_CODES = [
(0,), # dummy
(MPL_LINETO,),
(MPL_CURVE3, MPL_CURVE3),
(MPL_CURVE4, MPL_CURVE4, MPL_CURVE4),
(MPL_MOVETO,),
]
def to_matplotlib_path(paths: Iterable[NumpyPath2d], *, detect_holes=False):
"""Convert the given `paths` into a single :class:`matplotlib.path.Path`.
Matplotlib requires counter-clockwise oriented outside paths and clockwise oriented
holes. Set the `detect_holes` argument to ``True`` if this path orientation is not
yet satisfied.
"""
from matplotlib.path import Path
paths = list(paths)
if len(paths) == 0:
raise ValueError("one or more paths required")
if detect_holes:
# path orientation for holes is important, see #939
paths = orient_paths(paths)
vertices: list[np.ndarray] = []
codes: list[int] = []
for path in paths:
vertices.append(path.np_vertices())
codes.append(MPL_MOVETO)
for cmd in path.command_codes():
codes.extend(MPL_CODES[cmd])
points = np.concatenate(vertices)
try:
return Path(points, codes)
except Exception as e:
raise ValueError(f"matplotlib.path.Path({str(points)}, {str(codes)}): {str(e)}")
def single_paths(paths: Iterable[NumpyPath2d]) -> list[NumpyPath2d]:
single_paths_: list[NumpyPath2d] = []
for p in paths:
sub_paths = p.sub_paths()
if sub_paths:
single_paths_.extend(sub_paths)
return single_paths_
def orient_paths(paths: list[NumpyPath2d]) -> list[NumpyPath2d]:
"""Returns a new list of paths, with outer paths oriented counter-clockwise and
holes oriented clockwise.
"""
sub_paths: list[NumpyPath2d] = single_paths(paths)
if len(sub_paths) < 2:
return paths
polygons = nesting.make_polygon_structure(sub_paths)
outer_paths: list[NumpyPath2d]
holes: list[NumpyPath2d]
outer_paths, holes = nesting.winding_deconstruction(polygons)
path: NumpyPath2d
for path in outer_paths:
path.counter_clockwise()
for path in holes:
path.clockwise()
return outer_paths + holes
class NumpyShape3d(abc.ABC):
"""This is an optimization to store many 3D paths and polylines in a compact way
without sacrificing basic functions like transformation and bounding box calculation.
"""
_vertices: npt.NDArray[VertexNumpyType] = EMPTY_SHAPE
def extents(self) -> tuple[Vec3, Vec3]:
"""Returns the extents of the bounding box as tuple (extmin, extmax)."""
v = self._vertices
if len(v) > 0:
return Vec3(v.min(0)), Vec3(v.max(0))
else:
raise EmptyShapeError("empty shape has no extends")
@abc.abstractmethod
def clone(self) -> Self:
...
def np_vertices(self) -> npt.NDArray[VertexNumpyType]:
return self._vertices
def transform_inplace(self, m: Matrix44) -> None:
"""Transforms the vertices of the shape inplace."""
v = self._vertices
if len(v) == 0:
return
m.transform_array_inplace(v, 3)
def vertices(self) -> list[Vec3]:
"""Returns the shape vertices as list of :class:`Vec3`."""
return [Vec3(v) for v in self._vertices]
def bbox(self) -> BoundingBox:
"""Returns the bounding box of all vertices."""
return BoundingBox(self.extents())
class NumpyPoints3d(NumpyShape3d):
"""Represents an array of 3D points stored as a ndarray."""
def __init__(self, points: Optional[Iterable[UVec]]) -> None:
if points:
self._vertices = np.array(
[Vec3(v).xyz for v in points], dtype=VertexNumpyType
)
def clone(self) -> Self:
clone = self.__class__(None)
clone._vertices = self._vertices.copy()
return clone
__copy__ = clone
def __len__(self) -> int:
return len(self._vertices)