#!/usr/bin/env python
"""
Module implementing geometric shapes.
Each shape knows how to plot itself.
"""
try:
# noinspection PyUnresolvedReferences
from matplotlib import pyplot as plt
# noinspection PyUnresolvedReferences
from matplotlib import patches, path
_MATPLOTLIB_AVAILABLE = True
except ImportError: # pragma: no cover
_MATPLOTLIB_AVAILABLE = False
import cmath
import math
from abc import ABCMeta, abstractmethod
from io import BytesIO
from typing import Any, Optional, TypeVar, cast
import numpy as np
__all__ = ['Coordinate', 'Shape', 'Hexagon', 'Rectangle', 'Circle']
ComplexOrArray = TypeVar("ComplexOrArray", np.ndarray, complex)
[docs]class Coordinate:
"""
Base class for a coordinate in a 2D grid.
A Coordinate object knows its location in the grid (represented as a
complex number) and how to calculate the distance from it to another
location.
"""
def __init__(self, pos: complex):
"""
Initializes the Coordinate object.
Parameters
----------
pos : complex
Coordinate in the complex grid.
"""
self._pos: complex = pos
# xxxxxxxxxx pos property xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@property
def pos(self) -> complex:
"""
Get the coordinate position as a complex number.
Returns
-------
complex
The coordinate position (a complex number).
"""
return self._pos
@pos.setter
def pos(self, value: complex) -> None:
"""
Set the coordinate position.
Parameters
----------
value : complex
The new coordinate position (a complex number).
"""
self._pos = value
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[docs] def calc_dist(self, other: "Coordinate") -> float:
"""
Calculates the distance to another coordinate.
Parameters
----------
other : Coordinate
A different coordinate object.
Returns
-------
dist : float
Distance from self to the other coordinate.
"""
dist: float = np.abs(self.pos - other.pos)
return dist
[docs] def move_by_relative_coordinate(self, rel_pos: complex) -> None:
"""
Move from the current position to the relative coordinate.
This is equivalent to moving to a new position given by the current
position plus `coordinate`.
Parameters
----------
rel_pos : complex
Relative coordinate
"""
self.pos += rel_pos
[docs] def move_by_relative_polar_coordinate(self, radius: float,
angle: float) -> None:
"""
Move from the current position to the relative coordinate.
This is equivalent to moving to a new position given by the current
position plus a the provided coordinate.
Parameters
----------
radius : float
Distance of the movement in the direction given by `angle`.
angle : float
Angle (in radians) pointing the direction of the movement.
"""
rel_pos = cmath.rect(radius, angle)
self.move_by_relative_coordinate(rel_pos)
def __repr__(self) -> str: # pragma: no cover
"""
Representation of a Coordinate object.
Returns
-------
str
The string representation of the object.
"""
return "{0}({1})".format(self.__class__.__name__, self.pos)
[docs]class Shape(Coordinate):
"""
Base class for all 2D shapes.
Each subclass must implement the _get_vertex_positions method.
Parameters
----------
pos : complex
Coordinate of the shape in the complex grid.
radius : float
Radius of the shape. It must be positive.
rotation : float
Rotation of the shape in degrees.
"""
# The Shape class is an abstract class and all methods marked as
# 'abstract' must be implemented in a subclass.
__metaclass__ = ABCMeta
def __init__(self, pos: complex, radius: float, rotation: float = 0, **kw):
super().__init__(pos=pos, **kw)
self._radius = radius
self._rotation = rotation
# Properties for the plot representation
self.fill_face_bool = False
self.fill_color = 'r'
self.fill_opacity = 0.1
# Default figsize passed to matplotlib when the plot or the
# _repr_some_format_ methods are called. Note that if you passed
# the 'ax' argument in the plot method this will not be used.
self.figsize = (8, 8)
def __repr__(self) -> str: # pragma: no cover
"""
Representation of a Shape object.
Returns
-------
str
The string representation of the object.
"""
return "{0}(pos={1},radius={2},rotation={3})".format(
self.__class__.__name__, self.pos, self.radius, self.rotation)
# xxxxx radius property xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@property
def radius(self) -> float:
"""
Get method for the radius property.
Returns
-------
float
The Shape radius.
"""
return self._radius
@radius.setter
def radius(self, value: float) -> None:
"""
Set method for the radius property.
Parameters
----------
value : float
The new radius.
"""
self._radius = value
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxx rotation property xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@property
def rotation(self) -> float:
"""
Get method for the rotation property.
Returns
-------
float
The shape rotation.
"""
return self._rotation
@rotation.setter
def rotation(self, value: float) -> None:
"""
Set method for the rotation property.
Parameters
----------
value : float
The new shape rotation.
"""
self._rotation = value
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[docs] @abstractmethod
def _get_vertex_positions(self) -> np.ndarray: # pragma: no cover
"""
Calculates the vertex positions ignoring any rotation and
considering that the shape is at the origin (rotation and
translation will be added automatically later).
Returns
-------
vertex_positions : np.ndarray
The positions of the vertexes of the shape (as a 1D numpy
array).
Notes
-----
Not implemented. Must be implemented in a subclass and return a
one-dimensional numpy array (complex dtype) with the vertex
positions.
"""
raise NotImplementedError(
('get_vertex_positions still needs to be implemented in the '
'{0} class'.format(self.__class__.__name__)))
# xxxxx vertex property xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@property
def vertices_no_trans_no_rotation(self) -> np.ndarray: # pragma: no cover
"""
Get the shape vertexes without translation and rotation.
Returns
-------
vertex_positions : np.ndarray
The positions of the vertexes of the shape without any
translation or rotation (as a 1D numpy array).
"""
return self._get_vertex_positions()
@property
def vertices(self) -> np.ndarray:
"""
Get method for the vertices property.
Returns
-------
np.ndarray
The shape vertexes.
"""
vertex_positions: np.ndarray = self._get_vertex_positions()
vertex_positions2: np.ndarray = self.pos + Shape.calc_rotated_pos(
vertex_positions, self.rotation)
return vertex_positions2
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[docs] def is_point_inside_shape(self, point: complex) -> bool:
"""
Test is a point is inside the shape.
Parameters
----------
point
A single complex number.
Returns
-------
inside_or_not
True if `point` is inside the shape, False otherwise.
"""
mpl_path = path.Path(from_complex_array_to_real_matrix(self.vertices))
# This code is used with Matplotlib version 1.2 or higher.
return cast(bool, mpl_path.contains_point([point.real, point.imag]))
# xxxxx Code for Matplotlib version 1.1 xxxxxxxxxxxxxxxxxxxxxxxxxxx
# The code below was used for Matplotlib lower then version
# 1.2. However, since Matplotlib version 1.2 the pnpoly function is
# deprecated
# # pnpoly returns 1 if point is inside the polygon and 0 otherwise
# import matplotlib.nxutils as mnx
# return mnx.pnpoly(point.real, point.imag,
# from_complex_array_to_real_matrix(
# self.vertices)) == 1
# noinspection PyUnresolvedReferences
[docs] def get_border_point(
self,
angle: float,
ratio: Optional[float] = None) -> complex: # pylint: disable=R0914
"""
Calculates the coordinate of the point that intercepts the
border of the shape if we go from the origin with a given angle
(in degrees).
Parameters
----------
angle : float
Angle in degrees.
ratio : float
The ratio from the cell center to the border where the desired
point is located. This MUST be a value between 0 and 1.
Returns
-------
point : complex
A point in the line between the shape's center and the shape's
border with the desired angle. If ratio is equal to one the
point will be in the end of the line (touching the shape's
border)
"""
if ratio is None:
ratio = 1.0
angle_rad = np.pi * angle / 180.
# Which point we get if we walk a distance of cell radius in the
# desired angle direction?
point = cast(complex, self.pos + self._radius * np.exp(angle_rad * 1j))
# Calculates the distance of this point to all vertices and finds
# the closest vertices
dists = np.abs(self.vertices - point)
# Get the two closest vertices from point
closest_vertices = self.vertices[np.argsort(dists)[:2]]
# The equation of a straight line is given by "y = ax + b". We have
# two points in this line (the two closest vertices) and we can use
# them to find 'a' and 'b'. First let's find the different of these
# two closest vertexes
diff = closest_vertices[0] - closest_vertices[1]
# xxxxx Special case for a vertical line xxxxxxxxxxxxxxxxxxxxxxxxxx
# noinspection PyTypeChecker
if np.allclose(diff.real, 0.0, atol=1e-15):
# If the the real part of diff is equal to zero, that means
# that the straight line is actually a vertical
# line. Therefore, all we need to do to get the border point is
# to start from the shape's center and go with the desired
# angle until the value in the 'x' axis is equivalent to
# closest_vertices[0].real.
adjacent_side = closest_vertices[0].real - self.pos.real
side = np.tan(angle_rad) * adjacent_side
point = self.pos + adjacent_side + 1j * side
# Now all that is left to do is apply the ratio, which only
# means that the returned point is a linear combination between
# the shape's central position and the point at the border of
# the shape
return (1 - ratio) * self.pos + ratio * point
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Calculates the 'a' and 'b' in the line equation "y=ax+b"
a = diff.imag / diff.real
b = closest_vertices[1].imag - a * closest_vertices[1].real
# Note that is we start from self.pos and walk in the direction
# pointed by the angle by "some step" we should reach the line
# where the two closest vertexes are. If we can find this "step"
# then we will get our desired point.
# That is, for the step "z" we have
# self.pos + np.exp(1j * angle_rad) * z = complex(x, a * x + b)
# Which we can write as the system of equations
# self.pos.real + np.exp(1j * angle).real * z = x
# self.pos.imag + np.exp(1j * angle).imag * z = a * x + b
# Lets create some aliases for the constants so that
# A + B * z = x
# C + D * z = a * x + b
A = self.pos.real
B = np.exp(1j * angle_rad).real
C = self.pos.imag
D = np.exp(1j * angle_rad).imag
# Through some algebraic manipulation the correct step "z" is given
# by
z = (A * a + b - C) / (D - (a * B))
# Now we can finally find the desired point at the border of the
# shape
point = self.pos + np.exp(1j * angle_rad) * z
# Now all that is left to do is apply the ratio, which only means
# that the returned point is a linear combination between the
# shape's central position and the point at the border of the shape
return (1 - ratio) * self.pos + ratio * point
# noinspection PyShadowingNames,PyShadowingNames
[docs] def plot(self, ax: Any = None) -> None: # pragma: no cover
"""
Plot the shape using the matplotlib library.
Parameters
----------
ax : A matplotlib ax, optional
The ax where the shape will be plotted. If not provided, a new
figure (and ax) will be created.
Notes
-----
If an axes 'ax' is specified, then the shape is added to that
axes. Otherwise a new figure and axes are created and the shape is
plotted to that.
"""
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
_, ax = plt.subplots(figsize=self.figsize)
stand_alone_plot = True
if self.fill_face_bool:
# Matplotlib does not seem to allow us to set a different alpha
# value for the face and the edges. Therefore, we will need to
# plot twice to get that effect.
polygon_face = patches.Polygon(
from_complex_array_to_real_matrix(self.vertices),
True,
facecolor=self.fill_color,
edgecolor='none', # No edges
alpha=self.fill_opacity)
ax.add_patch(polygon_face)
polygon_edges = patches.Polygon(
from_complex_array_to_real_matrix(self.vertices),
True,
facecolor='none', # No face
edgecolor="black",
alpha=1)
ax.add_patch(polygon_edges)
if stand_alone_plot is True:
ax.plot()
plt.show()
# noinspection PyShadowingNames
[docs] def _repr_png_(self) -> Any: # pragma: no cover
"""
Return the PNG representation of the shape.
"""
return self._repr_some_format_('png')
[docs] def _repr_svg_(self) -> Any: # pragma: no cover
"""
Return the SVG representation of the shape.
"""
return self._repr_some_format_('svg')
[docs] @staticmethod
def calc_rotated_pos(cur_pos: ComplexOrArray,
angle: float) -> ComplexOrArray:
"""
Rotate the complex numbers in the `cur_pos` array by `angle` (in
degrees) around the origin.
Parameters
----------
cur_pos : complex | np.ndarray
The complex number(s) to be rotated.
angle: float
Angle in degrees to rotate the positions.
Returns
-------
rotated_pos : complex | np.ndarray
The rotate complex number(s).
"""
angle_rad = angle * np.pi / 180.
return cur_pos * np.exp(1j * angle_rad) # type: ignore
[docs]class Hexagon(Shape):
"""
Hexagon shape class.
Besides the `pos`, `radius` and `rotation` properties from the Shape
base class, the Hexagon also has a height property (read-only) from the
base of the Hexagon to its center.
Parameters
----------
pos : complex
Coordinate of the shape in the complex grid.
radius : float
Radius of the hexagon. It must be a positive number.
rotation : float
Rotation of the hexagon in degrees.
"""
def __init__(self, pos: complex, radius: float, rotation: float = 0, **kw):
super().__init__(pos=pos, radius=radius, rotation=rotation, **kw)
@property
def height(self) -> float:
"""
Get method for the height property.
Returns
-------
float
The height of the Hexagon.
"""
return self._radius * math.sqrt(3.) / 2.0
[docs] def _get_vertex_positions(self) -> np.ndarray:
"""
Calculates the vertex positions ignoring any rotation and
considering that the hexagon is at the origin (rotation and
translation will be added automatically later).
Returns
-------
vertex_positions : np.ndarray
The positions of the vertexes of the shape.
"""
vertex_positions: np.ndarray = np.zeros(6, dtype=complex)
vertex_positions[0] = complex(-self._radius / 2., -self.height)
# noinspection PyTypeChecker
angles = np.linspace(0, 240, 5) * np.pi / 180.
for k in range(5):
# noinspection PyUnresolvedReferences
vertex_positions[k + 1] = (vertex_positions[k] +
self._radius * np.exp(angles[k] * 1j))
return vertex_positions
[docs]class Rectangle(Shape):
"""
Rectangle shape class.
The rectangle is initialized from two coordinates as well as from
the rotation.
Parameters
----------
first : complex
First coordinate (without rotation).
second : complex
Second coordinate (without rotation).
rotation : float
Rotation of the rectangle in degrees.
"""
def __init__(self,
first: complex,
second: complex,
rotation: float = 0,
**kw):
central_pos = (first + second) / 2
radius = np.abs(second - central_pos)
super().__init__(pos=central_pos,
radius=radius,
rotation=rotation,
**kw)
self._lower_coord = complex(min(first.real, second.real),
min(first.imag, second.imag))
self._upper_coord = complex(max(first.real, second.real),
max(first.imag, second.imag))
def __repr__(self) -> str: # pragma: no cover
"""
Representation of a Rectangle object.
Returns
-------
str
The string representation of the Rectangle object.
"""
return "{0}(A={1},B={2},rotation={3})".format(self.__class__.__name__,
self._lower_coord,
self._upper_coord,
self.rotation)
[docs] def _get_vertex_positions(self) -> np.ndarray:
"""
Calculates the vertex positions ignoring any rotation and
considering that the rectangle is at the origin (rotation and
translation will be added automatically later).
Returns
-------
vertex_positions : np.ndarray
The positions of the vertexes of the shape.
"""
vertex_positions: np.ndarray = np.zeros(4, dtype=complex)
A = self._lower_coord - self.pos
B = self._upper_coord - self.pos
vertex_positions[0] = A
vertex_positions[1] = complex(B.real, A.imag)
vertex_positions[2] = B
vertex_positions[3] = complex(A.real, B.imag)
return vertex_positions
[docs] def is_point_inside_shape(self, point: complex) -> bool:
"""
Test is a point is inside the rectangle
Parameters
----------
point : complex
A single complex number.
Returns
-------
bool
True if `point` is inside the rectangle, False otherwise.
"""
min_x = min(self._lower_coord.real, self._upper_coord.real)
max_x = max(self._lower_coord.real, self._upper_coord.real)
min_y = min(self._lower_coord.imag, self._upper_coord.imag)
max_y = max(self._lower_coord.imag, self._upper_coord.imag)
point_x = point.real
point_y = point.imag
if point_x < min_x:
return False
if point_x > max_x:
return False
if point_y < min_y:
return False
if point_y > max_y:
return False
return True
[docs]class Circle(Shape):
"""
Circle shape class.
A circle is initialized only from a coordinate and a radius.
Parameters
----------
pos : complex
Coordinate of the center of the circle.
radius : float
Circle's radius.
"""
def __init__(self, pos: complex, radius: float):
super().__init__(pos=pos, radius=radius)
[docs] def _get_vertex_positions(self) -> np.ndarray:
"""
Calculates the vertex positions considering that the circle is
at the origin (translation will be added automatically later).
Returns
-------
vertex_positions : np.ndarray
The positions of the vertexes of the shape.
Notes
-----
It does not make much sense to get the vertexes of a circle, since
a circle 'has' infinite vertexes. However, for consistence with the
Shape's class interface the _get_vertex_positions is implemented
such that it returns a subset of the circle vertexes. The number of
returned vertexes was arbitrarily chosen as 12.
"""
num_vertexes = 12
angles: np.ndarray = np.linspace(
0, (num_vertexes - 1.) / num_vertexes * 2 * np.pi, num_vertexes)
vertex_positions: np.ndarray = self._radius * np.exp(1j * angles)
return vertex_positions
[docs] def get_border_point(self,
angle: float,
ratio: Optional[float] = None) -> complex:
"""
Calculates the coordinate of the point that intercepts the
border of the circle if we go from the origin with a given angle
(in degrees).
Parameters
----------
angle : float
Angle in degrees
ratio : float
The ratio from the cell center to the border where the desired
point is located. It must be a value between 0 and 1.
Returns
-------
point : complex
A point in the line between the circle's center and the
circle's border with the desired angle. If ratio is equal to
one the point will be in the end of the line (touching the
circle's border)
"""
if ratio is None:
ratio = 1.0
angle_rad = np.pi * angle / 180.
return cast(complex,
self.pos + np.exp(1j * angle_rad) * self.radius * ratio)
[docs] def is_point_inside_shape(self, point: complex) -> bool:
"""
Test is a point is inside the circle
Parameters
----------
point
A single complex number.
Returns
-------
inside_or_not
True if `point` is inside the circle, False otherwise.
"""
return cast(bool, np.abs(self.pos - point) < self.radius)
# noinspection PyShadowingNames,PyShadowingNames
[docs] def plot(self, ax: Any = None) -> None: # pragma: no cover
"""
Plot the circle using the Matplotlib library.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the shape will be plotted. If not provided,
a new figure (and axis) will be created.
Notes
-----
If an axes 'ax' is specified, then the shape is added to that
axes. Otherwise a new figure and axes are created and the shape is
plotted to that.
"""
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
ax = plt.axes()
stand_alone_plot = True
if self.fill_face_bool:
# Matplotlib does not seem to allow us to set a different alpha
# value for the face and the edges. Therefore, we will need to
# plot twice to get that effect.
circle_face = patches.Circle(
[self.pos.real, self.pos.imag],
self.radius,
facecolor=self.fill_color,
edgecolor='none', # No edges
alpha=self.fill_opacity)
ax.add_patch(circle_face)
circle_edges = patches.Circle(
[self.pos.real, self.pos.imag],
self.radius,
facecolor='none', # No face
alpha=1)
ax.add_patch(circle_edges)
if stand_alone_plot is True:
ax.plot()
plt.show()
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
def from_complex_array_to_real_matrix(a: np.ndarray) -> np.ndarray:
"""
Convert an array of complex numbers to a matrix of real numbers.
Parameters
----------
a : np.ndarray
A numpy array of complex numbers with N elements.
Returns
-------
np.ndarray
The converted array with dimension N x 2. That is, a 2D numpy
array.
Notes
-----
We use complex number to represent coordinates, where the real part is
the 'x' coordinate and the imaginary part is the 'y' coordinate pf a
point.
However, matplotlib methods need the coordinates to be separated. For
instance, a vector of complex coordinates must be converted to a matrix
with two columns, where the two columns are the 'x' and 'y' coordinates
and each row corresponds to a point.
The method from_complex_array_to_real_matrix does exactly this
conversion.
Examples
--------
>>> a = np.array([1+2j, 3-4j, 5+6j])
>>> print(from_complex_array_to_real_matrix(a))
[[ 1. 2.]
[ 3. -4.]
[ 5. 6.]]
"""
num_elem = np.size(a)
a.dtype = a.real.dtype
a = np.reshape(a, (num_elem, 2))
return a
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
if __name__ == '__main__1': # pragma: no cover
ax = plt.axes()
h = Hexagon(2 + 3j, 2, 30)
# print "Border Point is: {0}".format(point)
h.plot(ax)
POINT1 = h.get_border_point(90, 0.9)
ax.plot(POINT1.real, POINT1.imag, 'ro')
POINT2 = h.get_border_point(10, 0.9)
ax.plot(POINT2.real, POINT2.imag, 'go')
POINT3 = h.get_border_point(30, 0.9)
ax.plot(POINT3.real, POINT3.imag, 'bo')
POINT4 = h.get_border_point(107, 1)
ax.plot(POINT4.real, POINT4.imag, 'bo')
ax.plot(h.pos.real, h.pos.imag, 'ro')
# print h.vertices
ax.axis('equal')
plt.show()
if __name__ == '__main__': # pragma: no cover
ax = plt.axes()
h = Hexagon(0, 1)
h.rotation = 30
h.fill_face_bool = True
plt.hold(True)
r = Rectangle(0, 2 + 1j, 0)
r.rotation = 15
r.fill_face_bool = True
r.fill_color = 'b'
c = Circle(0.2 + 0.4j, 1)
c.fill_face_bool = True
c.fill_color = 'g'
r.plot(ax)
h.plot(ax)
c.plot(ax)
ax.plot()
plt.axis('equal')
plt.show()
if __name__ == '__main__1': # pragma: no cover
ax = plt.axes()
c = Circle(2 + 3j, 2)
# print "Border Point is: {0}".format(point)
c.plot(ax)
for v in c.vertices:
ax.plot(v.real, v.imag, 'bo')
ax.plot(c.pos.real, c.pos.imag, 'ro')
# print c.vertices
ax.axis('equal')
plt.show()