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

508 lines
17 KiB
Python

# Copyright (c) 2020-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Optional,
Iterator,
Iterable,
Any,
Callable,
)
from typing_extensions import Self
from ezdxf.math import (
Vec3,
NULLVEC,
OCS,
Bezier3P,
Bezier4P,
Matrix44,
has_clockwise_orientation,
UVec,
BoundingBox,
)
from .commands import (
Command,
LineTo,
MoveTo,
Curve3To,
Curve4To,
PathElement,
)
__all__ = ["Path"]
MAX_DISTANCE = 0.01
MIN_SEGMENTS = 4
G1_TOL = 1e-4
_slots = ("_vertices", "_start_index", "_commands", "_has_sub_paths", "_user_data")
class Path:
__slots__ = _slots
def __init__(self, start: UVec = NULLVEC):
# stores all command vertices in a contiguous list:
self._vertices: list[Vec3] = [Vec3(start)]
# start index of each command
self._start_index: list[int] = []
self._commands: list[Command] = []
self._has_sub_paths = False
self._user_data: Any = None # should be immutable data!
@classmethod
def from_vertices_and_commands(
cls, vertices: list[Vec3], command_codes: list[Command], user_data: Any = None
) -> Self:
"""Create path instances from a list of vertices and a list of commands."""
# Used for fast conversion from NumpyPath2d to Path.
# This is "hacky" but also 8x faster than the correct way using only public
# methods and properties.
new_path = cls()
if len(vertices) == 0:
return new_path
new_path._vertices = vertices
new_path._commands = command_codes
new_path._start_index = make_vertex_index(command_codes)
new_path._has_sub_paths = any(cmd == Command.MOVE_TO for cmd in command_codes)
new_path._user_data = user_data
return new_path
def transform(self, m: Matrix44) -> Self:
"""Returns a new transformed path.
Args:
m: transformation matrix of type :class:`~ezdxf.math.Matrix44`
"""
new_path = self.clone()
new_path._vertices = list(m.transform_vertices(self._vertices))
return new_path
def bbox(self) -> BoundingBox:
"""Returns the bounding box of all control vertices as
:class:`~ezdxf.math.BoundingBox` instance.
"""
return BoundingBox(self.control_vertices())
def __len__(self) -> int:
"""Returns count of path elements."""
return len(self._commands)
def __getitem__(self, item) -> PathElement:
"""Returns the path element at given index, slicing is not supported."""
if isinstance(item, slice):
raise TypeError("slicing not supported")
cmd = self._commands[item]
index = self._start_index[item]
vertices = self._vertices
if cmd == Command.MOVE_TO:
return MoveTo(vertices[index])
if cmd == Command.LINE_TO:
return LineTo(vertices[index])
if cmd == Command.CURVE3_TO: # end, ctrl
return Curve3To(vertices[index + 1], vertices[index])
if cmd == Command.CURVE4_TO:
return Curve4To( # end, ctrl1, ctrl2
vertices[index + 2],
vertices[index],
vertices[index + 1],
)
raise ValueError(f"Invalid command: {cmd}")
def __iter__(self) -> Iterator[PathElement]:
return (self[i] for i in range(len(self._commands)))
def commands(self) -> list[PathElement]:
"""Returns all path elements as list."""
return list(self.__iter__())
def __copy__(self) -> Self:
"""Returns a new copy of :class:`Path` with shared immutable data."""
copy = self.__class__()
# vertices itself are immutable - no copying required
copy._vertices = self._vertices.copy()
self._copy_properties(copy)
return copy
def _copy_properties(self, clone: Path) -> None:
assert len(self._vertices) == len(clone._vertices)
clone._commands = self._commands.copy()
clone._start_index = self._start_index.copy()
clone._has_sub_paths = self._has_sub_paths
# copy by reference: user data should be immutable data!
clone._user_data = self._user_data
clone = __copy__
@property
def user_data(self) -> Any:
"""Attach arbitrary user data to a :class:`Path` object.
The user data is copied by reference, no deep copy is applied
therefore a mutable state is shared between copies.
"""
return self._user_data
@user_data.setter
def user_data(self, data: Any):
self._user_data = data
@property
def start(self) -> Vec3:
""":class:`Path` start point, resetting the start point of an empty
path is possible.
"""
return self._vertices[0]
@start.setter
def start(self, location: UVec) -> None:
if self._commands:
raise ValueError("Requires an empty path.")
else:
self._vertices[0] = Vec3(location)
@property
def end(self) -> Vec3:
""":class:`Path` end point."""
return self._vertices[-1]
def control_vertices(self) -> list[Vec3]:
"""Yields all path control vertices in consecutive order."""
if self._commands:
return list(self._vertices)
return []
def command_codes(self) -> list[int]:
"""Internal API."""
return list(self._commands)
@property
def is_closed(self) -> bool:
"""Returns ``True`` if the start point is close to the end point."""
vertices = self._vertices
if len(vertices) > 1:
return vertices[0].isclose(vertices[-1])
return False
@property
def has_lines(self) -> bool:
"""Returns ``True`` if the path has any line segments."""
return Command.LINE_TO in self._commands
@property
def has_curves(self) -> bool:
"""Returns ``True`` if the path has any curve segments."""
return (
Command.CURVE4_TO in self._commands or Command.CURVE3_TO in self._commands
)
@property
def has_sub_paths(self) -> bool:
"""Returns ``True`` if the path is a :term:`Multi-Path` object that
contains multiple sub-paths.
"""
return self._has_sub_paths
def has_clockwise_orientation(self) -> bool:
"""Returns ``True`` if 2D path has clockwise orientation, ignores
z-axis of all control vertices.
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")
return has_clockwise_orientation(self._vertices)
def append_path_element(self, cmd: PathElement) -> None:
"""Append a single path element."""
t = cmd.type
if t == Command.LINE_TO:
self.line_to(cmd.end)
elif t == Command.MOVE_TO:
self.move_to(cmd.end)
elif t == Command.CURVE3_TO:
self.curve3_to(cmd.end, cmd.ctrl) # type: ignore
elif t == Command.CURVE4_TO:
self.curve4_to(cmd.end, cmd.ctrl1, cmd.ctrl2) # type: ignore
else:
raise ValueError(f"Invalid command: {t}")
def line_to(self, location: UVec) -> None:
"""Add a line from actual path end point to `location`."""
self._commands.append(Command.LINE_TO)
self._start_index.append(len(self._vertices))
self._vertices.append(Vec3(location))
def move_to(self, location: UVec) -> None:
"""Start a new sub-path at `location`. This creates a gap between the
current end-point and the start-point of the new sub-path. This converts
the instance into a :term:`Multi-Path` object.
If the :meth:`move_to` command is the first command, the start point of
the path will be reset to `location`.
"""
commands = self._commands
if not commands:
self._vertices[0] = Vec3(location)
return
self._has_sub_paths = True
if commands[-1] == Command.MOVE_TO:
# replace last move to command
commands.pop()
self._vertices.pop()
self._start_index.pop()
commands.append(Command.MOVE_TO)
self._start_index.append(len(self._vertices))
self._vertices.append(Vec3(location))
def curve3_to(self, location: UVec, ctrl: UVec) -> None:
"""Add a quadratic Bèzier-curve from actual path end point to
`location`, `ctrl` is the control point for the quadratic Bèzier-curve.
"""
self._commands.append(Command.CURVE3_TO)
self._start_index.append(len(self._vertices))
self._vertices.extend((Vec3(ctrl), Vec3(location)))
def curve4_to(self, location: UVec, ctrl1: UVec, ctrl2: UVec) -> None:
"""Add a cubic Bèzier-curve from actual path end point to `location`,
`ctrl1` and `ctrl2` are the control points for the cubic Bèzier-curve.
"""
self._commands.append(Command.CURVE4_TO)
self._start_index.append(len(self._vertices))
self._vertices.extend((Vec3(ctrl1), Vec3(ctrl2), Vec3(location)))
def close(self) -> None:
"""Close path by adding a line segment from the end point to the start
point.
"""
if not self.is_closed:
self.line_to(self.start)
def close_sub_path(self) -> None:
"""Close last sub-path by adding a line segment from the end point to
the start point of the last sub-path. Behaves like :meth:`close` for
:term:`Single-Path` instances.
"""
if self.has_sub_paths:
start_point = self._start_of_last_sub_path()
assert (
start_point is not None
), "internal error: required MOVE_TO command not found"
if not self.end.isclose(start_point):
self.line_to(start_point)
else:
self.close()
def _start_of_last_sub_path(self) -> Optional[Vec3]:
move_to = Command.MOVE_TO
commands = self._commands
index = len(commands) - 1
# The first command at index 0 is never MOVE_TO!
while index > 0:
if commands[index] == move_to:
return self._vertices[self._start_index[index]]
index -= 1
return None
def reversed(self) -> Self:
"""Returns a new :class:`Path` with reversed commands and control
vertices.
"""
path = self.clone()
if not path._commands:
return path
if path._commands[-1] == Command.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!
path._commands.pop()
path._vertices.pop()
path._start_index.pop()
path._has_sub_paths = any( # is still a multi-path?
cmd == Command.MOVE_TO for cmd in path._commands
)
path._commands.reverse()
path._vertices.reverse()
path._start_index = make_vertex_index(path._commands)
return path
def clockwise(self) -> Self:
"""Returns new :class:`Path` in clockwise orientation.
Raises:
TypeError: can't detect orientation of a :term:`Multi-Path` object
"""
if self.has_clockwise_orientation():
return self.clone()
else:
return self.reversed()
def counter_clockwise(self) -> Self:
"""Returns new :class:`Path` in counter-clockwise orientation.
Raises:
TypeError: can't detect orientation of a :term:`Multi-Path` object
"""
if self.has_clockwise_orientation():
return self.reversed()
else:
return self.clone()
def approximate(self, segments: int = 20) -> Iterator[Vec3]:
"""Approximate path by vertices, `segments` is the count of
approximation segments for each Bézier curve.
Does not yield any vertices for empty paths, where only a start point
is present!
Approximation of :term:`Multi-Path` objects is possible, but gaps are
indistinguishable from line segments.
"""
def curve3(p0: Vec3, p1: Vec3, p2: Vec3) -> Iterator[Vec3]:
return iter(Bezier3P((p0, p1, p2)).approximate(segments))
def curve4(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3) -> Iterator[Vec3]:
return iter(Bezier4P((p0, p1, p2, p3)).approximate(segments))
return self._approximate(curve3, curve4)
def flattening(self, distance: float, segments: int = 4) -> Iterator[Vec3]:
"""Approximate path by vertices and use adaptive recursive flattening
to approximate Bèzier curves. The argument `segments` is the
minimum count of approximation segments for each curve, if the distance
from the center of the approximation segment to the curve is bigger than
`distance` the segment will be subdivided.
Does not yield any vertices for empty paths, where only a start point
is present!
Flattening of :term:`Multi-Path` objects is possible, but gaps are
indistinguishable from line segments.
Args:
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.
segments: minimum segment count per Bézier curve
"""
def curve3(p0: Vec3, p1: Vec3, p2: Vec3) -> Iterator[Vec3]:
if distance == 0.0:
raise ValueError(f"invalid max distance: 0.0")
return iter(Bezier3P((p0, p1, p2)).flattening(distance, segments))
def curve4(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3) -> Iterator[Vec3]:
if distance == 0.0:
raise ValueError(f"invalid max distance: 0.0")
return iter(Bezier4P((p0, p1, p2, p3)).flattening(distance, segments))
return self._approximate(curve3, curve4)
def _approximate(self, curve3: Callable, curve4: Callable) -> Iterator[Vec3]:
if not self._commands:
return
start = self._vertices[0]
yield start
vertices = self._vertices
for si, cmd in zip(self._start_index, self._commands):
if cmd == Command.LINE_TO or cmd == Command.MOVE_TO:
end_location = vertices[si]
yield end_location
elif cmd == Command.CURVE3_TO:
ctrl, end_location = vertices[si : si + 2]
pts = curve3(start, ctrl, end_location)
next(pts) # skip first vertex
yield from pts
elif cmd == Command.CURVE4_TO:
ctrl1, ctrl2, end_location = vertices[si : si + 3]
pts = curve4(start, ctrl1, ctrl2, end_location)
next(pts) # skip first vertex
yield from pts
else:
raise ValueError(f"Invalid command: {cmd}")
start = end_location
def to_wcs(self, ocs: OCS, elevation: float) -> None:
"""Transform path from given `ocs` to WCS coordinates inplace."""
self._vertices = list(
ocs.to_wcs(v.replace(z=float(elevation))) for v in self._vertices
)
def sub_paths(self) -> Iterator[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`.
"""
path = self.__class__(start=self.start)
path._user_data = self._user_data
move_to = Command.MOVE_TO
for cmd in self.commands():
if cmd.type == move_to:
yield path
path = self.__class__(start=cmd.end)
path._user_data = self._user_data
else:
path.append_path_element(cmd)
yield path
def extend_multi_path(self, path: Path) -> None:
"""Extend the path by another path. The source path is automatically a
:term:`Multi-Path` object, even if the previous end point matches the
start point of the appended path. Ignores paths without any commands
(empty paths).
"""
if len(path):
self.move_to(path.start)
for cmd in path.commands():
self.append_path_element(cmd)
def append_path(self, path: Path) -> None:
"""Append another path to this path. Adds a :code:`self.line_to(path.start)`
if the end of this path != the start of appended path.
"""
if len(path) == 0:
return # do not append an empty path
if self._commands:
if not self.end.isclose(path.start):
self.line_to(path.start)
else:
self.start = path.start
for cmd in path.commands():
self.append_path_element(cmd)
CMD_SIZE = {
Command.MOVE_TO: 1,
Command.LINE_TO: 1,
Command.CURVE3_TO: 2,
Command.CURVE4_TO: 3,
}
def make_vertex_index(command_codes: Iterable[Command]) -> list[int]:
cmd_size = CMD_SIZE
start: int = 1
start_index: list[int] = []
for code in command_codes:
start_index.append(start)
start += cmd_size[code]
return start_index