Source code for data_morph.shapes.bases.line_collection
"""Base class for shapes that are composed of lines."""
from __future__ import annotations
from collections.abc import Iterable
from numbers import Number
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from ...plotting.style import plot_with_custom_style
from .shape import Shape
[docs]
class LineCollection(Shape):
"""
Class representing a shape consisting of one or more lines.
Parameters
----------
*lines : Iterable[Iterable[numbers.Number]]
An iterable of two (x, y) pairs representing the endpoints
of a line.
"""
def __init__(self, *lines: Iterable[Iterable[Number]]) -> None:
# check that lines with the same starting and ending points raise an error
for line in lines:
start, end = line
if np.allclose(start, end):
raise ValueError(f'Line {line} has the same start and end point')
self.lines = np.array(lines)
"""Iterable[Iterable[numbers.Number]]: An iterable
of two (x, y) pairs representing the endpoints of a line."""
def __repr__(self) -> str:
return self._recursive_repr('lines')
[docs]
def distance(self, x: Number, y: Number) -> float:
"""
Calculate the minimum distance from the lines of this shape
to a point (x, y).
Parameters
----------
x, y : numbers.Number
Coordinates of a point in 2D space.
Returns
-------
float
The minimum distance from the lines of this shape to the
point (x, y).
Notes
-----
Implementation based on `this Stack Overflow answer`_.
.. _this Stack Overflow answer: https://stackoverflow.com/a/58781995
"""
point = np.array([x, y])
start_points = self.lines[:, 0, :]
end_points = self.lines[:, 1, :]
tangent_vector = end_points - start_points
normalized_tangent_vectors = np.divide(
tangent_vector,
np.hypot(tangent_vector[:, 0], tangent_vector[:, 1]).reshape(-1, 1),
)
# row-wise dot products of 2D vectors
signed_parallel_distance_start = np.multiply(
start_points - point, normalized_tangent_vectors
).sum(axis=1)
signed_parallel_distance_end = np.multiply(
point - end_points, normalized_tangent_vectors
).sum(axis=1)
clamped_parallel_distance = np.maximum.reduce(
[
signed_parallel_distance_start,
signed_parallel_distance_end,
np.zeros(signed_parallel_distance_start.shape[0]),
]
)
# row-wise cross products of 2D vectors
diff = point - start_points
perpendicular_distance_component = (
diff[..., 0] * normalized_tangent_vectors[..., 1]
- diff[..., 1] * normalized_tangent_vectors[..., 0]
)
return np.min(
np.hypot(clamped_parallel_distance, perpendicular_distance_component)
)
[docs]
@plot_with_custom_style
def plot(self, ax: Axes | None = None) -> Axes:
"""
Plot the shape.
Parameters
----------
ax : matplotlib.axes.Axes, optional
An optional :class:`~matplotlib.axes.Axes` object to plot on.
Returns
-------
matplotlib.axes.Axes
The :class:`~matplotlib.axes.Axes` object containing the plot.
"""
if not ax:
fig, ax = plt.subplots(layout='constrained')
fig.get_layout_engine().set(w_pad=0.2, h_pad=0.2)
_ = ax.axis('equal')
for start, end in self.lines:
ax.plot(*list(zip(start, end)), 'k-')
return ax