Skip to content

Commit

Permalink
Merge pull request #157 from chris-greening/add-rotate-method
Browse files Browse the repository at this point in the history
Add rotate method
  • Loading branch information
chris-greening authored Apr 12, 2023
2 parents 1f39af5 + 3d73f22 commit d6d38c4
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 27 deletions.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

setuptools.setup(
name="spyrograph",
version="0.26.0",
version="0.27.0",
author="Chris Greening",
author_email="chris@christophergreening.com",
description="Library for drawing spirographs in Python",
Expand Down
15 changes: 11 additions & 4 deletions spyrograph/core/_cycloid.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ class _Cycloid(_Trochoid):
def __init__(
self, R: Number, r: Number, thetas: List[Number] = None,
theta_start: Number = None, theta_stop: Number = None,
theta_step: Number = None, origin: Tuple[Number, Number] = (0, 0)
theta_step: Number = None, origin: Tuple[Number, Number] = (0, 0),
orientation: Number = 0
) -> None:
super().__init__(R, r, r, thetas, theta_start, theta_stop, theta_step, origin)
super().__init__(R, r, r, thetas, theta_start, theta_stop, theta_step, origin, orientation)
# pylint: disable=pointless-string-statement
"""Instantiate a cycloid curve from given input parameters. A
hypocycloid is a curve drawn by tracing a point from a circle as it
Expand Down Expand Up @@ -51,6 +52,8 @@ def __init__(
cannot be set at the same time as thetas argument
origin : Tuple[Number, Number] = (0, 0)
Custom origin to center the shapes at. Default is (0,0)
orientation : Number = 0
Angle of rotation for the shape
"""

@classmethod
Expand Down Expand Up @@ -199,7 +202,8 @@ def create_range(
def n_cusps(
cls, R: Number, n: int, thetas: List[Number] = None,
theta_start: Number = None, theta_stop: Number = None,
theta_step: Number = None, origin: Tuple[Number, Number] = (0, 0)
theta_step: Number = None, origin: Tuple[Number, Number] = (0, 0),
orientation: Number = 0
) -> "Cycloid":
"""
Create and return a cycloid with a specified number of cusps.
Expand All @@ -224,6 +228,8 @@ def n_cusps(
to built-in range or np.arange).
origin : Tuple[Number, Number], optional, default: (0, 0)
Custom origin to center the shapes at. Default is (0, 0).
orientation : Number = 0
Angle of rotation for the shape
Returns
-------
Expand All @@ -243,5 +249,6 @@ def n_cusps(
theta_start=theta_start,
theta_stop=theta_stop,
theta_step=theta_step,
origin=origin
origin=origin,
orientation=orientation
)
7 changes: 7 additions & 0 deletions spyrograph/core/_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
except ImportError:
ImageGrab = None

def _apply_rotation(x: "np.array", y: "np.array", angle: Number):
"""Return rotated parametrized values"""
cos_angle, sin_angle = np.cos(angle), np.sin(angle)
rotation_matrix = np.array([[cos_angle, -sin_angle], [sin_angle, cos_angle]])
rotated_coords = np.dot(rotation_matrix, np.array([x, y]))
return rotated_coords[0], rotated_coords[1]

def _validate_theta(
thetas: List[Number], theta_start: Number, theta_stop: Number,
theta_step: Number
Expand Down
108 changes: 86 additions & 22 deletions spyrograph/core/_trochoid.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from spyrograph.core._misc import (
_get_products_of_inputs, _validate_only_one_iterable, _draw_animation,
_validate_theta, _save_trace, _get_animate_screen_size
_validate_theta, _save_trace, _get_animate_screen_size, _apply_rotation
)

try:
Expand All @@ -33,7 +33,8 @@ class _Trochoid(ABC):
def __init__(
self, R: Number, r: Number, d: Number, thetas: List[Number] = None,
theta_start: Number = None, theta_stop: Number = None,
theta_step: Number = None, origin: Tuple[Number, Number] = (0, 0)
theta_step: Number = None, origin: Tuple[Number, Number] = (0, 0),
orientation: Number = 0
) -> None:
"""Model of a trochoid curve from given input parameters. A trochoid is
a curve drawn by tracing a point from a circle as it rolls around the
Expand Down Expand Up @@ -66,29 +67,18 @@ def __init__(
cannot be set at the same time as thetas argument
origin : Tuple[Number, Number] = (0, 0)
Custom origin to center the shapes at. Default is (0,0)
orientation : Number = 0
Angle of rotation for the shape
"""
self.R = R
self.r = r
self.d = d
self.thetas = _validate_theta(thetas, theta_start, theta_stop, theta_step)
self.origin = origin
self.orientation = orientation

if self.R <= 0 or self.r <= 0 or self.d <= 0:
raise ValueError((
"Negative and/or zero input parameters were passed. "
"Please only pass positive values"
))

self.x = np.array([self._calculate_x(theta) for theta in self.thetas])
self.y = np.array([self._calculate_y(theta) for theta in self.thetas])
self.x += self.origin[0]
self.y += self.origin[1]
self.min_x = min(self.x)
self.max_x = max(self.x)
self.min_y = min(self.y)
self.max_y = max(self.y)

self.coords = list(zip(self.x, self.y, self.thetas))
self._validate_inputs()
self._calculate_path()

def translate(self, x: Number = 0, y: Number = 0) -> "_Trochoid":
"""
Expand Down Expand Up @@ -118,14 +108,16 @@ def translate(self, x: Number = 0, y: Number = 0) -> "_Trochoid":
r=self.r,
d=self.d,
thetas=self.thetas,
origin=(self.origin[0]+x, self.origin[1]+y)
origin=(self.origin[0]+x, self.origin[1]+y),
orientation=self.orientation
)
except TypeError:
translated_shape = self.__class__(
R=self.R,
r=self.r,
thetas=self.thetas,
origin=(self.origin[0]+x, self.origin[1]+y)
origin=(self.origin[0]+x, self.origin[1]+y),
orientation=self.orientation
)
return translated_shape

Expand Down Expand Up @@ -164,14 +156,64 @@ def scale(self, factor: Number) -> Union["_Trochoid", "_Cycloid"]:
r=self.r*factor,
d=self.d*factor,
thetas=self.thetas,
origin=self.origin
origin=self.origin,
orientation=self.orientation
)
except TypeError:
scaled_shape = self.__class__(
R=self.R*factor,
r=self.r*factor,
thetas=self.thetas,
origin=self.origin
origin=self.origin,
orientation=self.orientation
)
return scaled_shape

def rotate(self, angle: float, degrees: bool = False):
"""
Rotate the shape by the given angle (in radians).
This method creates a new instance of the shape with the updated orientation attribute,
keeping the original shape unchanged.
Parameters
----------
angle : float
The angle to rotate the shape by, in radians
degrees : bool
Rotate in degrees
Returns
-------
rotated_shape : instance of the shape's class
A new instance of the shape with the updated orientation.
Examples
--------
>>> from spyrograph import Hypotrochoid
>>> import numpy as np
>>> shape = Hypotrochoid(R=233, r=200, d=233, thetas=np.arange(0, 100*np.pi, .5))
>>> rotated_shape = shape.rotate(np.pi / 4) # Rotate the shape by 45 degrees
"""
# pylint: disable=no-value-for-parameter
if degrees:
angle = np.deg2rad(angle)
try:
scaled_shape = self.__class__(
R=self.R,
r=self.r,
d=self.d,
thetas=self.thetas,
origin=self.origin,
orientation=self.orientation + angle
)
except TypeError:
scaled_shape = self.__class__(
R=self.R,
r=self.r,
thetas=self.thetas,
origin=self.origin,
orientation=self.orientation + angle
)
return scaled_shape

Expand Down Expand Up @@ -573,6 +615,28 @@ def create_range(
))
return shapes

def _calculate_path(self) -> None:
"""Calculate the parametrized path"""
self.x = np.array([self._calculate_x(theta) for theta in self.thetas])
self.y = np.array([self._calculate_y(theta) for theta in self.thetas])
self.x += self.origin[0]
self.y += self.origin[1]
self.x, self.y = _apply_rotation(self.x, self.y, self.orientation)
self.min_x = min(self.x)
self.max_x = max(self.x)
self.min_y = min(self.y)
self.max_y = max(self.y)

self.coords = list(zip(self.x, self.y, self.thetas))

def _validate_inputs(self) -> None:
"""Validate input parameters"""
if self.R <= 0 or self.r <= 0 or self.d <= 0:
raise ValueError((
"Negative and/or zero input parameters were passed. "
"Please only pass positive values"
))

def _show_full_path(self, pre_draw_turtle: "turtle.Turtle") -> turtle.Turtle:
"""Draw the full path prior to tracing"""
# pylint: disable=no-member, unused-variable
Expand Down
9 changes: 9 additions & 0 deletions tests/_cycloid.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,12 @@ def test_n_cusps_custom_origin(self, thetas):
)
assert ((custom_origin_obj.x - base_obj.x).round() == 54.0).all()
assert ((custom_origin_obj.y - base_obj.y).round() == -233.0).all()

def test_n_cusps_custom_orientation(self, thetas) -> None:
custom_orientation_obj = self.class_name.n_cusps(
R = 300,
n = 2,
thetas=thetas,
orientation = 1
)
assert custom_orientation_obj.orientation == 1
48 changes: 48 additions & 0 deletions tests/_trochoid.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,44 @@ def test_scale_factor_parameters_smaller(self, instance):
assert smaller_scaled_instance.d == instance.d*.5
assert (smaller_scaled_instance.thetas == instance.thetas).all()

def test_scale_origin_is_preserved(self, thetas):
if issubclass(self.class_name, _Cycloid):
translated_shape = self.class_name(
R=100,
r=200,
thetas=thetas,
origin=(100,100)
)
elif issubclass(self.class_name, _Trochoid):
translated_shape = self.class_name(
R=100,
r=200,
d=300,
thetas=thetas,
origin=(100, 100)
)
scaled_shape = translated_shape.scale(2)
assert scaled_shape.origin == translated_shape.origin

def test_rotate_origin_is_preserved(self, thetas):
if issubclass(self.class_name, _Cycloid):
translated_shape = self.class_name(
R=100,
r=200,
thetas=thetas,
origin=(100, 100)
)
elif issubclass(self.class_name, _Trochoid):
translated_shape = self.class_name(
R=100,
r=200,
d=300,
thetas=thetas,
origin=(100, 100)
)
rotated_shape = translated_shape.rotate(2)
assert rotated_shape.origin == rotated_shape.origin

def test_create_range_theta_inputs(self, thetas):
R = 5
r = 3
Expand Down Expand Up @@ -276,3 +314,13 @@ def test_dataframe_property(self, instance) -> None:
assert all(instance.df["x"].to_numpy() == instance.x)
assert all(instance.df["y"].to_numpy() == instance.y)
assert all(instance.df["theta"].to_numpy() == instance.thetas)

def test_rotate_orientation(self, instance) -> None:
rotated_shape = instance.rotate(.1)
assert instance.orientation == 0
assert rotated_shape.orientation == .1

def test_parameters_are_preserved(self, instance) -> None:
rotated_shape = instance.rotate(1)
assert instance.R == rotated_shape.R
assert instance.r == rotated_shape.r

0 comments on commit d6d38c4

Please sign in to comment.