"""Path module for graphics package."""
from dataclasses import dataclass
from math import sin, cos, pi
from collections import deque
from typing_extensions import Self
import numpy as np
from .core import StyleMixin
from .batch import Batch
from .shape import Shape
from .common import Point, common_properties
from ..helpers.validation import validate_args
from .all_enums import PathOperation as PathOps
from .all_enums import Types
from ..canvas.style_map import shape_style_map, ShapeStyle, shape_args
from ..geometry.bezier import Bezier
from ..geometry.hobby import hobby_shape
from ..geometry.geometry import (
homogenize,
positive_angle,
polar_to_cartesian,
sine_points,
)
from ..geometry.ellipse import (
ellipse_point,
ellipse_tangent,
elliptic_arc_points,
)
from ..geometry.geometry import extended_line, line_angle, line_by_point_angle_length
from .affine import translation_matrix, rotation_matrix
from ..settings.settings import defaults
array = np.array
[docs]
@dataclass
class Operation:
"""An operation for a Path object.
Attributes:
subtype (Types): The subtype of the operation.
data (tuple): The data associated with the operation.
name (str): The name of the operation.
"""
subtype: Types
data: tuple
name: str = ""
def __post_init__(self):
"""Post-initialization to set the type and common properties."""
self.type = Types.PATH_OPERATION
common_properties(self, False)
[docs]
class LinPath(Batch, StyleMixin):
"""LinerPath.
A LinPath object is a container for various linear elements.
Path objects can be transformed like other Shape and Batch objects.
"""
def __init__(self, start: Point = (0, 0), **kwargs):
"""Initialize a Path object.
Args:
start (Point, optional): The starting point of the path. Defaults to (0, 0).
**kwargs: Additional keyword arguments.
"""
if "style" in kwargs:
self.__dict__["style"] = kwargs["style"]
del kwargs["style"]
else:
self.__dict__["style"] = ShapeStyle()
self.__dict__["_style_map"] = shape_style_map
self._set_aliases()
valid_args = shape_args
validate_args(kwargs, valid_args)
self.pos = start
self.start = start
self.angle = pi / 2 # heading angle
self.operations = []
self.objects = []
self.even_odd = True # False is non-zero winding rule
super().__init__(**kwargs)
self.subtype = Types.PATH
self.cur_shape = Shape([start])
self.append(self.cur_shape)
self.rc = self.r_coord # alias for r_coord
self.rp = self.r_polar # alias for rel_polar
self.handles = []
self.stack = deque()
for key, value in kwargs.items():
setattr(self, key, value)
common_properties(self)
self.closed = False
def __getattr__(self, name):
"""Retrieve an attribute of the shape.
Args:
name (str): The attribute name to return.
Returns:
Any: The value of the attribute.
Raises:
AttributeError: If the attribute cannot be found.
"""
try:
res = super().__getattr__(name)
except AttributeError:
res = self.__dict__[name]
return res
def __bool__(self):
"""Return True if the path has operations.
Batch may have no elements yet still be True.
Returns:
bool: True if the path has operations.
"""
return bool(self.operations)
def _create_object(self):
"""Create an object using the last operation."""
PO = PathOps
op = self.operations[-1]
op_type = op.subtype
data = op.data
if op_type in [PO.MOVE_TO, PO.R_MOVE]:
self.cur_shape = Shape([data])
self.append(self.cur_shape)
self.objects.append(None)
elif op_type in [PO.LINE_TO, PO.R_LINE, PO.H_LINE, PO.V_LINE, PO.FORWARD]:
self.objects.append(Shape(data))
self.cur_shape.append(data[1])
elif op_type in [PO.SEGMENTS]:
self.objects.append(Shape(data[1]))
self.cur_shape.extend(data[1])
elif op_type in [PO.SINE, PO.BLEND_SINE]:
self.objects.append(Shape(data[0]))
self.cur_shape.extend(data[0])
elif op_type in [PO.CUBIC_TO, PO.QUAD_TO]:
n_points = defaults["n_bezier_points"]
curve = Bezier(data, n_points=n_points)
self.objects.append(curve)
self.cur_shape.extend(curve.vertices[1:])
if op_type == PO.CUBIC_TO:
self.handles.extend([(data[0], data[1]), (data[2], data[3])])
else:
self.handles.append((data[0], data[1]))
self.handles.append((data[1], data[2]))
elif op_type in [PO.HOBBY_TO]:
n_points = defaults['n_hobby_points']
curve = hobby_shape(data[1], n_points=n_points)
self.objects.append(Shape(curve.vertices))
elif op_type in [PO.ARC, PO.BLEND_ARC]:
self.objects.append(Shape(data[-1]))
self.cur_shape.extend(data[-1][1:])
elif op_type in [PO.CLOSE]:
self.cur_shape.closed = True
self.cur_shape = Shape([self.pos])
self.objects.append(None)
self.append(self.cur_shape)
else:
raise ValueError(f"Invalid operation type: {op_type}")
[docs]
def copy(self) -> "LinPath":
"""Return a copy of the path.
Returns:
LinPath: The copied path object.
"""
new_path = LinPath(start=self.start, style=self.style)
new_path.pos = self.pos
new_path.angle = self.angle
new_path.operations = self.operations.copy()
new_path.objects = []
for obj in self.objects:
if obj is not None:
new_path.objects.append(obj.copy())
new_path.even_odd = self.even_odd
new_path.cur_shape = self.cur_shape.copy()
new_path.handles = self.handles.copy()
new_path.stack = deque(self.stack)
return new_path
def _add(self, pos, op, data, pnt2=None, **kwargs):
"""Add an operation to the path.
Args:
pos (Point): The position of the operation.
op (PathOps): The operation type.
data (tuple): The data for the operation.
pnt2 (Point, optional): An optional second point for the operation. Defaults to None.
**kwargs: Additional keyword arguments.
"""
self.operations.append(Operation(op, data))
if op in [PathOps.ARC, PathOps.BLEND_ARC, PathOps.SINE, PathOps.BLEND_SINE]:
self.angle = data[1]
else:
if pnt2 is not None:
self.angle = line_angle(pnt2, pos)
else:
self.angle = line_angle(self.pos, pos)
self._create_object()
if "name" in kwargs:
setattr(self, kwargs["name"], self.operations[-1])
list(pos)[:2]
self.pos = pos
[docs]
def push(self):
"""Push the current position onto the stack."""
self.stack.append((self.pos, self.angle))
[docs]
def pop(self):
"""Pop the last position from the stack."""
if self.stack:
self.pos, self.angle = self.stack.pop()
[docs]
def r_coord(self, dx: float, dy: float) -> Point:
"""Return the relative coordinates of a point in a
coordinate system with the path's origin and y-axis aligned
with the path.angle.
Args:
dx (float): The x offset.
dy (float): The y offset.
Returns:
tuple: The relative coordinates.
"""
x, y = self.pos[:2]
theta = self.angle - pi / 2
x1 = dx * cos(theta) - dy * sin(theta) + x
y1 = dx * sin(theta) + dy * cos(theta) + y
return x1, y1
[docs]
def r_polar(self, r: float, angle: float) -> Point:
"""Return the relative coordinates of a point in a polar
coordinate system with the path's origin and 0 degree axis aligned
with the path.angle.
Args:
r (float): The radius.
angle (float): The angle in radians.
Returns:
tuple: The relative coordinates.
"""
x, y = polar_to_cartesian(r, angle + self.angle - pi / 2)[:2]
x1, y1 = self.pos[:2]
return x1 + x, y1 + y
[docs]
def line_to(self, point: Point, **kwargs) -> Self:
"""Add a line to the path.
Args:
point (Point): The end point of the line.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
self._add(point, PathOps.LINE_TO, (self.pos, point))
return self
[docs]
def forward(self, length: float, **kwargs) -> Self:
"""Extend the path by the given length.
Args:
length (float): The length to extend.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
Raises:
ValueError: If the path angle is not set.
"""
if self.angle is None:
raise ValueError("Path angle is not set.")
else:
x, y = line_by_point_angle_length(self.pos, self.angle, length)[1][:2]
self._add((x, y), PathOps.FORWARD, (self.pos, (x, y)))
return self
[docs]
def move_to(self, point: Point, **kwargs) -> Self:
"""Move the path to a new point.
Args:
point (Point): The new point.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
self._add(point, PathOps.MOVE_TO, point)
return self
[docs]
def r_line(self, dx: float, dy: float, **kwargs) -> Self:
"""Add a relative line to the path.
Args:
dx (float): The x offset.
dy (float): The y offset.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
point = self.pos[0] + dx, self.pos[1] + dy
self._add(point, PathOps.R_LINE, (self.pos, point))
return self
[docs]
def r_move(self, dx: float = 0, dy: float = 0, **kwargs) -> Self:
"""Move the path to a new relative point.
Args:
dx (float): The x offset.
dy (float): The y offset.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
x, y = self.pos[:2]
point = (x + dx, y + dy)
self._add(point, PathOps.R_MOVE, point)
return self
[docs]
def h_line(self, length: float, **kwargs) -> Self:
"""Add a horizontal line to the path.
Args:
length (float): The length of the line.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
x, y = self.pos[0] + length, self.pos[1]
self._add((x, y), PathOps.H_LINE, (self.pos, (x, y)))
return self
[docs]
def v_line(self, length: float, **kwargs) -> Self:
"""Add a vertical line to the path.
Args:
length (float): The length of the line.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
x, y = self.pos[0], self.pos[1] + length
self._add((x, y), PathOps.V_LINE, (self.pos, (x, y)))
return self
[docs]
def segments(self, points, **kwargs) -> Self:
"""Add a series of line segments to the path.
Args:
points (list): The points of the segments.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
self._add(points[-1], PathOps.SEGMENTS, (self.pos, points), pnt2=points[-2], **kwargs)
return self
[docs]
def cubic_to(self, control1: Point, control2: Point, end: Point, *args, **kwargs) -> Self:
"""Add a Bézier curve with two control points to the path. Multiple blended curves can be added
by providing additional arguments.
Args:
control1 (Point): The first control point.
control2 (Point): The second control point.
end (Point): The end point of the curve.
*args: Additional arguments for blended curves.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
self._add(
end,
PathOps.CUBIC_TO,
(self.pos, control1, control2, end),
pnt2=control2,
**kwargs,
)
return self
[docs]
def hobby_to(self, points, **kwargs) -> Self:
"""Add a Hobby curve to the path.
Args:
points (list): The points of the Hobby curve.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
self._add(points[-1], PathOps.HOBBY_TO, (self.pos, points))
return self
[docs]
def quad_to(self, control: Point, end: Point, *args, **kwargs) -> Self:
"""Add a quadratic Bézier curve to the path. Multiple blended curves can be added by providing
additional arguments.
Args:
control (Point): The control point.
end (Point): The end point of the curve.
*args: Additional arguments for blended curves.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
Raises:
ValueError: If an argument does not have exactly two elements.
"""
self._add(
end, PathOps.QUAD_TO, (self.pos[:2], control, end[:2]), pnt2=control, **kwargs
)
pos = end
for arg in args:
if len(arg) != 2:
raise ValueError("Invalid number of arguments for curve.")
if isinstance(arg[0], (int, float)):
# (length, end)
length = arg[0]
control = extended_line(length, control, pos)
end = arg[1]
self._add(end, PathOps.QUAD_TO, (pos, control, end), pnt2=control)
pos = end
elif isinstance(arg[0], (list, tuple)):
# (control, end)
control = arg[0]
end = arg[1]
self._add(end, PathOps.QUAD_TO, (pos, control, end), pnt2=control)
pos = end
return self
[docs]
def blend_cubic(self, control1_length, control2: Point, end: Point, **kwargs) -> Self:
"""Add a cubic Bézier curve to the path where the first control point is computed based on a length.
Args:
control1_length (float): The length to the first control point.
control2 (Point): The second control point.
end (Point): The end point of the curve.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
c1 = line_by_point_angle_length(self.pos, self.angle, control1_length)[1]
self._add(
end,
PathOps.CUBIC_TO,
(self.pos, c1, control2, end),
pnt2=control2,
**kwargs,
)
return self
[docs]
def blend_quad(self, control_length, end: Point, **kwargs) -> Self:
"""Add a quadratic Bézier curve to the path where the control point is computed based on a length.
Args:
control_length (float): The length to the control point.
end (Point): The end point of the curve.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
pos = list(self.pos[:2])
c1 = line_by_point_angle_length(pos, self.angle, control_length)[1]
self._add(end, PathOps.QUAD_TO, (pos, c1, end), pnt2=c1, **kwargs)
return self
[docs]
def arc(
self,
radius_x: float,
radius_y: float,
start_angle: float,
span_angle: float,
rot_angle: float = 0,
n_points=None,
**kwargs,
) -> Self:
"""Add an arc to the path. The arc is defined by an ellipse (with rx as half-width and ry as half-height).
The sign of the span angle determines the drawing direction.
Args:
radius_x (float): The x radius of the arc.
radius_y (float): The y radius of the arc.
start_angle (float): The starting angle of the arc.
span_angle (float): The span angle of the arc.
rot_angle (float, optional): The rotation angle of the arc. Defaults to 0.
n_points (int, optional): The number of points to use for the arc. Defaults to None.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
rx = radius_x
ry = radius_y
start_angle = positive_angle(start_angle)
clockwise = span_angle < 0
if n_points is None:
n_points = defaults["n_arc_points"]
points = elliptic_arc_points((0, 0), rx, ry, start_angle, span_angle, n_points)
start = points[0]
end = points[-1]
# Translate the start to the current position and rotate by the rotation angle.
dx = self.pos[0] - start[0]
dy = self.pos[1] - start[1]
rotocenter = start
if rot_angle != 0:
points = (
homogenize(points)
[docs]
@ rotation_matrix(rot_angle, rotocenter)
@ translation_matrix(dx, dy)
)
else:
points = homogenize(points) @ translation_matrix(dx, dy)
tangent_angle = ellipse_tangent(rx, ry, *end) + rot_angle
if clockwise:
tangent_angle += pi
pos = points[-1]
self._add(
pos,
PathOps.ARC,
(pos, tangent_angle, rx, ry, start_angle, span_angle, rot_angle, points),
)
return self
def blend_arc(
self,
radius_x: float,
radius_y: float,
start_angle: float,
span_angle: float,
sharp=False,
n_points=None,
**kwargs,
) -> Self:
"""Add a blended elliptic arc to the path.
Args:
radius_x (float): The x radius of the arc.
radius_y (float): The y radius of the arc.
start_angle (float): The starting angle of the arc.
span_angle (float): The span angle of the arc.
sharp (bool, optional): Whether the arc is sharp. Defaults to False.
n_points (int, optional): The number of points to use for the arc. Defaults to None.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
rx = radius_x
ry = radius_y
start_angle = positive_angle(start_angle)
clockwise = span_angle < 0
if n_points is None:
n_points = defaults["n_arc_points"]
points = elliptic_arc_points((0, 0), rx, ry, start_angle, span_angle, n_points)
start = points[0]
end = points[-1]
# Translate the start to the current position and rotate by the computed rotation angle.
dx = self.pos[0] - start[0]
dy = self.pos[1] - start[1]
rotocenter = start
tangent = ellipse_tangent(rx, ry, *start)
rot_angle = self.angle - tangent
if clockwise:
rot_angle += pi
if sharp:
rot_angle += pi
points = (
homogenize(points)
[docs]
@ rotation_matrix(rot_angle, rotocenter)
@ translation_matrix(dx, dy)
)
tangent_angle = ellipse_tangent(rx, ry, *end) + rot_angle
if clockwise:
tangent_angle += pi
pos = points[-1][:2]
self._add(
pos,
PathOps.ARC,
(pos, tangent_angle, rx, ry, start_angle, span_angle, rot_angle, points),
)
return self
def sine(
self,
period: float = 40,
amplitude: float = 20,
duration: float = 40,
phase_angle: float = 0,
rot_angle: float = 0,
damping: float = 0,
n_points: int = 100,
**kwargs,
) -> Self:
"""Add a sine wave to the path.
Args:
period (float, optional): _description_. Defaults to 40.
amplitude (float, optional): _description_. Defaults to 20.
duration (float, optional): _description_. Defaults to 1.
n_points (int, optional): _description_. Defaults to 100.
phase_angle (float, optional): _description_. Defaults to 0.
damping (float, optional): _description_. Defaults to 0.
rot_angle (float, optional): _description_. Defaults to 0.
Returns:
Path: The path object.
"""
points = sine_points(
period, amplitude, duration, n_points, phase_angle, damping
)
if rot_angle != 0:
points = homogenize(points) @ rotation_matrix(rot_angle, points[0])
points = homogenize(points) @ translation_matrix(*self.pos[:2])
angle = line_angle(points[-2], points[-1])
self._add(points[-1], PathOps.SINE, (points, angle))
return self
[docs]
def blend_sine(
self,
period: float = 40,
amplitude: float = 20,
duration: float = 40,
phase_angle: float = 0,
damping: float = 0,
n_points: int = 100,
**kwargs,
) -> Self:
"""Add a blended sine wave to the path.
Args:
amplitude (float): The amplitude of the wave.
frequency (float): The frequency of the wave.
length (float): The length of the wave.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
points = sine_points(
period, amplitude, duration, n_points, phase_angle, damping
)
start_angle = line_angle(points[0], points[1])
rot_angle = self.angle - start_angle
points = homogenize(points) @ rotation_matrix(rot_angle, points[0])
points = homogenize(points) @ translation_matrix(*self.pos[:2])
angle = line_angle(points[-2], points[-1])
self._add(points[-1], PathOps.SINE, (points, angle))
return self
[docs]
def close(self, **kwargs) -> Self:
"""Close the path.
Args:
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
self._add(self.pos, PathOps.CLOSE, None, **kwargs)
return self
@property
def vertices(self):
"""Return the vertices of the path.
Returns:
list: The vertices of the path.
"""
vertices = []
for obj in self.objects:
if obj is not None:
vertices.extend(obj.vertices)
return vertices
[docs]
def set_style(self, name, value, **kwargs) -> Self:
"""Set the style of the path.
Args:
name (str): The name of the style.
value (Any): The value of the style.
**kwargs: Additional keyword arguments.
Returns:
Path: The path object.
"""
self.operations.append((PathOps.STYLE, (name, value, kwargs)))
return self
def _update(self, xform_matrix: array, reps: int = 0) -> Batch:
"""Used internally. Update the shape with a transformation matrix.
Args:
xform_matrix (array): The transformation matrix.
reps (int, optional): The number of repetitions, defaults to 0.
Returns:
Batch: The updated shape or a batch of shapes.
"""
if reps == 0:
for obj in self.objects:
if obj is not None:
obj._update(xform_matrix)
res = self
else:
paths = [self]
path = self
for _ in range(reps):
path = path.copy()
path._update(xform_matrix)
paths.append(path)
res = Batch(paths)
return res