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