|
"""Geometry objects for use by wrapping pathways.""" |
|
|
|
from abc import ABC, abstractmethod |
|
|
|
from sympy import Integer, acos, pi, sqrt, sympify, tan |
|
from sympy.core.relational import Eq |
|
from sympy.functions.elementary.trigonometric import atan2 |
|
from sympy.polys.polytools import cancel |
|
from sympy.physics.vector import Vector, dot |
|
from sympy.simplify.simplify import trigsimp |
|
|
|
|
|
__all__ = [ |
|
'WrappingGeometryBase', |
|
'WrappingCylinder', |
|
'WrappingSphere', |
|
] |
|
|
|
|
|
class WrappingGeometryBase(ABC): |
|
"""Abstract base class for all geometry classes to inherit from. |
|
|
|
Notes |
|
===== |
|
|
|
Instances of this class cannot be directly instantiated by users. However, |
|
it can be used to created custom geometry types through subclassing. |
|
|
|
""" |
|
|
|
@property |
|
@abstractmethod |
|
def point(cls): |
|
"""The point with which the geometry is associated.""" |
|
pass |
|
|
|
@abstractmethod |
|
def point_on_surface(self, point): |
|
"""Returns ``True`` if a point is on the geometry's surface. |
|
|
|
Parameters |
|
========== |
|
point : Point |
|
The point for which it's to be ascertained if it's on the |
|
geometry's surface or not. |
|
|
|
""" |
|
pass |
|
|
|
@abstractmethod |
|
def geodesic_length(self, point_1, point_2): |
|
"""Returns the shortest distance between two points on a geometry's |
|
surface. |
|
|
|
Parameters |
|
========== |
|
|
|
point_1 : Point |
|
The point from which the geodesic length should be calculated. |
|
point_2 : Point |
|
The point to which the geodesic length should be calculated. |
|
|
|
""" |
|
pass |
|
|
|
@abstractmethod |
|
def geodesic_end_vectors(self, point_1, point_2): |
|
"""The vectors parallel to the geodesic at the two end points. |
|
|
|
Parameters |
|
========== |
|
|
|
point_1 : Point |
|
The point from which the geodesic originates. |
|
point_2 : Point |
|
The point at which the geodesic terminates. |
|
|
|
""" |
|
pass |
|
|
|
def __repr__(self): |
|
"""Default representation of a geometry object.""" |
|
return f'{self.__class__.__name__}()' |
|
|
|
|
|
class WrappingSphere(WrappingGeometryBase): |
|
"""A solid spherical object. |
|
|
|
Explanation |
|
=========== |
|
|
|
A wrapping geometry that allows for circular arcs to be defined between |
|
pairs of points. These paths are always geodetic (the shortest possible). |
|
|
|
Examples |
|
======== |
|
|
|
To create a ``WrappingSphere`` instance, a ``Symbol`` denoting its radius |
|
and ``Point`` at which its center will be located are needed: |
|
|
|
>>> from sympy import symbols |
|
>>> from sympy.physics.mechanics import Point, WrappingSphere |
|
>>> r = symbols('r') |
|
>>> pO = Point('pO') |
|
|
|
A sphere with radius ``r`` centered on ``pO`` can be instantiated with: |
|
|
|
>>> WrappingSphere(r, pO) |
|
WrappingSphere(radius=r, point=pO) |
|
|
|
Parameters |
|
========== |
|
|
|
radius : Symbol |
|
Radius of the sphere. This symbol must represent a value that is |
|
positive and constant, i.e. it cannot be a dynamic symbol, nor can it |
|
be an expression. |
|
point : Point |
|
A point at which the sphere is centered. |
|
|
|
See Also |
|
======== |
|
|
|
WrappingCylinder: Cylindrical geometry where the wrapping direction can be |
|
defined. |
|
|
|
""" |
|
|
|
def __init__(self, radius, point): |
|
"""Initializer for ``WrappingSphere``. |
|
|
|
Parameters |
|
========== |
|
|
|
radius : Symbol |
|
The radius of the sphere. |
|
point : Point |
|
A point on which the sphere is centered. |
|
|
|
""" |
|
self.radius = radius |
|
self.point = point |
|
|
|
@property |
|
def radius(self): |
|
"""Radius of the sphere.""" |
|
return self._radius |
|
|
|
@radius.setter |
|
def radius(self, radius): |
|
self._radius = radius |
|
|
|
@property |
|
def point(self): |
|
"""A point on which the sphere is centered.""" |
|
return self._point |
|
|
|
@point.setter |
|
def point(self, point): |
|
self._point = point |
|
|
|
def point_on_surface(self, point): |
|
"""Returns ``True`` if a point is on the sphere's surface. |
|
|
|
Parameters |
|
========== |
|
|
|
point : Point |
|
The point for which it's to be ascertained if it's on the sphere's |
|
surface or not. This point's position relative to the sphere's |
|
center must be a simple expression involving the radius of the |
|
sphere, otherwise this check will likely not work. |
|
|
|
""" |
|
point_vector = point.pos_from(self.point) |
|
if isinstance(point_vector, Vector): |
|
point_radius_squared = dot(point_vector, point_vector) |
|
else: |
|
point_radius_squared = point_vector**2 |
|
return Eq(point_radius_squared, self.radius**2) == True |
|
|
|
def geodesic_length(self, point_1, point_2): |
|
r"""Returns the shortest distance between two points on the sphere's |
|
surface. |
|
|
|
Explanation |
|
=========== |
|
|
|
The geodesic length, i.e. the shortest arc along the surface of a |
|
sphere, connecting two points can be calculated using the formula: |
|
|
|
.. math:: |
|
|
|
l = \arccos\left(\mathbf{v}_1 \cdot \mathbf{v}_2\right) |
|
|
|
where $\mathbf{v}_1$ and $\mathbf{v}_2$ are the unit vectors from the |
|
sphere's center to the first and second points on the sphere's surface |
|
respectively. Note that the actual path that the geodesic will take is |
|
undefined when the two points are directly opposite one another. |
|
|
|
Examples |
|
======== |
|
|
|
A geodesic length can only be calculated between two points on the |
|
sphere's surface. Firstly, a ``WrappingSphere`` instance must be |
|
created along with two points that will lie on its surface: |
|
|
|
>>> from sympy import symbols |
|
>>> from sympy.physics.mechanics import (Point, ReferenceFrame, |
|
... WrappingSphere) |
|
>>> N = ReferenceFrame('N') |
|
>>> r = symbols('r') |
|
>>> pO = Point('pO') |
|
>>> pO.set_vel(N, 0) |
|
>>> sphere = WrappingSphere(r, pO) |
|
>>> p1 = Point('p1') |
|
>>> p2 = Point('p2') |
|
|
|
Let's assume that ``p1`` lies at a distance of ``r`` in the ``N.x`` |
|
direction from ``pO`` and that ``p2`` is located on the sphere's |
|
surface in the ``N.y + N.z`` direction from ``pO``. These positions can |
|
be set with: |
|
|
|
>>> p1.set_pos(pO, r*N.x) |
|
>>> p1.pos_from(pO) |
|
r*N.x |
|
>>> p2.set_pos(pO, r*(N.y + N.z).normalize()) |
|
>>> p2.pos_from(pO) |
|
sqrt(2)*r/2*N.y + sqrt(2)*r/2*N.z |
|
|
|
The geodesic length, which is in this case is a quarter of the sphere's |
|
circumference, can be calculated using the ``geodesic_length`` method: |
|
|
|
>>> sphere.geodesic_length(p1, p2) |
|
pi*r/2 |
|
|
|
If the ``geodesic_length`` method is passed an argument, the ``Point`` |
|
that doesn't lie on the sphere's surface then a ``ValueError`` is |
|
raised because it's not possible to calculate a value in this case. |
|
|
|
Parameters |
|
========== |
|
|
|
point_1 : Point |
|
Point from which the geodesic length should be calculated. |
|
point_2 : Point |
|
Point to which the geodesic length should be calculated. |
|
|
|
""" |
|
for point in (point_1, point_2): |
|
if not self.point_on_surface(point): |
|
msg = ( |
|
f'Geodesic length cannot be calculated as point {point} ' |
|
f'with radius {point.pos_from(self.point).magnitude()} ' |
|
f'from the sphere\'s center {self.point} does not lie on ' |
|
f'the surface of {self} with radius {self.radius}.' |
|
) |
|
raise ValueError(msg) |
|
point_1_vector = point_1.pos_from(self.point).normalize() |
|
point_2_vector = point_2.pos_from(self.point).normalize() |
|
central_angle = acos(point_2_vector.dot(point_1_vector)) |
|
geodesic_length = self.radius*central_angle |
|
return geodesic_length |
|
|
|
def geodesic_end_vectors(self, point_1, point_2): |
|
"""The vectors parallel to the geodesic at the two end points. |
|
|
|
Parameters |
|
========== |
|
|
|
point_1 : Point |
|
The point from which the geodesic originates. |
|
point_2 : Point |
|
The point at which the geodesic terminates. |
|
|
|
""" |
|
pA, pB = point_1, point_2 |
|
pO = self.point |
|
pA_vec = pA.pos_from(pO) |
|
pB_vec = pB.pos_from(pO) |
|
|
|
if pA_vec.cross(pB_vec) == 0: |
|
msg = ( |
|
f'Can\'t compute geodesic end vectors for the pair of points ' |
|
f'{pA} and {pB} on a sphere {self} as they are diametrically ' |
|
f'opposed, thus the geodesic is not defined.' |
|
) |
|
raise ValueError(msg) |
|
|
|
return ( |
|
pA_vec.cross(pB.pos_from(pA)).cross(pA_vec).normalize(), |
|
pB_vec.cross(pA.pos_from(pB)).cross(pB_vec).normalize(), |
|
) |
|
|
|
def __repr__(self): |
|
"""Representation of a ``WrappingSphere``.""" |
|
return ( |
|
f'{self.__class__.__name__}(radius={self.radius}, ' |
|
f'point={self.point})' |
|
) |
|
|
|
|
|
class WrappingCylinder(WrappingGeometryBase): |
|
"""A solid (infinite) cylindrical object. |
|
|
|
Explanation |
|
=========== |
|
|
|
A wrapping geometry that allows for circular arcs to be defined between |
|
pairs of points. These paths are always geodetic (the shortest possible) in |
|
the sense that they will be a straight line on the unwrapped cylinder's |
|
surface. However, it is also possible for a direction to be specified, i.e. |
|
paths can be influenced such that they either wrap along the shortest side |
|
or the longest side of the cylinder. To define these directions, rotations |
|
are in the positive direction following the right-hand rule. |
|
|
|
Examples |
|
======== |
|
|
|
To create a ``WrappingCylinder`` instance, a ``Symbol`` denoting its |
|
radius, a ``Vector`` defining its axis, and a ``Point`` through which its |
|
axis passes are needed: |
|
|
|
>>> from sympy import symbols |
|
>>> from sympy.physics.mechanics import (Point, ReferenceFrame, |
|
... WrappingCylinder) |
|
>>> N = ReferenceFrame('N') |
|
>>> r = symbols('r') |
|
>>> pO = Point('pO') |
|
>>> ax = N.x |
|
|
|
A cylinder with radius ``r``, and axis parallel to ``N.x`` passing through |
|
``pO`` can be instantiated with: |
|
|
|
>>> WrappingCylinder(r, pO, ax) |
|
WrappingCylinder(radius=r, point=pO, axis=N.x) |
|
|
|
Parameters |
|
========== |
|
|
|
radius : Symbol |
|
The radius of the cylinder. |
|
point : Point |
|
A point through which the cylinder's axis passes. |
|
axis : Vector |
|
The axis along which the cylinder is aligned. |
|
|
|
See Also |
|
======== |
|
|
|
WrappingSphere: Spherical geometry where the wrapping direction is always |
|
geodetic. |
|
|
|
""" |
|
|
|
def __init__(self, radius, point, axis): |
|
"""Initializer for ``WrappingCylinder``. |
|
|
|
Parameters |
|
========== |
|
|
|
radius : Symbol |
|
The radius of the cylinder. This symbol must represent a value that |
|
is positive and constant, i.e. it cannot be a dynamic symbol. |
|
point : Point |
|
A point through which the cylinder's axis passes. |
|
axis : Vector |
|
The axis along which the cylinder is aligned. |
|
|
|
""" |
|
self.radius = radius |
|
self.point = point |
|
self.axis = axis |
|
|
|
@property |
|
def radius(self): |
|
"""Radius of the cylinder.""" |
|
return self._radius |
|
|
|
@radius.setter |
|
def radius(self, radius): |
|
self._radius = radius |
|
|
|
@property |
|
def point(self): |
|
"""A point through which the cylinder's axis passes.""" |
|
return self._point |
|
|
|
@point.setter |
|
def point(self, point): |
|
self._point = point |
|
|
|
@property |
|
def axis(self): |
|
"""Axis along which the cylinder is aligned.""" |
|
return self._axis |
|
|
|
@axis.setter |
|
def axis(self, axis): |
|
self._axis = axis.normalize() |
|
|
|
def point_on_surface(self, point): |
|
"""Returns ``True`` if a point is on the cylinder's surface. |
|
|
|
Parameters |
|
========== |
|
|
|
point : Point |
|
The point for which it's to be ascertained if it's on the |
|
cylinder's surface or not. This point's position relative to the |
|
cylinder's axis must be a simple expression involving the radius of |
|
the sphere, otherwise this check will likely not work. |
|
|
|
""" |
|
relative_position = point.pos_from(self.point) |
|
parallel = relative_position.dot(self.axis) * self.axis |
|
point_vector = relative_position - parallel |
|
if isinstance(point_vector, Vector): |
|
point_radius_squared = dot(point_vector, point_vector) |
|
else: |
|
point_radius_squared = point_vector**2 |
|
return Eq(trigsimp(point_radius_squared), self.radius**2) == True |
|
|
|
def geodesic_length(self, point_1, point_2): |
|
"""The shortest distance between two points on a geometry's surface. |
|
|
|
Explanation |
|
=========== |
|
|
|
The geodesic length, i.e. the shortest arc along the surface of a |
|
cylinder, connecting two points. It can be calculated using Pythagoras' |
|
theorem. The first short side is the distance between the two points on |
|
the cylinder's surface parallel to the cylinder's axis. The second |
|
short side is the arc of a circle between the two points of the |
|
cylinder's surface perpendicular to the cylinder's axis. The resulting |
|
hypotenuse is the geodesic length. |
|
|
|
Examples |
|
======== |
|
|
|
A geodesic length can only be calculated between two points on the |
|
cylinder's surface. Firstly, a ``WrappingCylinder`` instance must be |
|
created along with two points that will lie on its surface: |
|
|
|
>>> from sympy import symbols, cos, sin |
|
>>> from sympy.physics.mechanics import (Point, ReferenceFrame, |
|
... WrappingCylinder, dynamicsymbols) |
|
>>> N = ReferenceFrame('N') |
|
>>> r = symbols('r') |
|
>>> pO = Point('pO') |
|
>>> pO.set_vel(N, 0) |
|
>>> cylinder = WrappingCylinder(r, pO, N.x) |
|
>>> p1 = Point('p1') |
|
>>> p2 = Point('p2') |
|
|
|
Let's assume that ``p1`` is located at ``N.x + r*N.y`` relative to |
|
``pO`` and that ``p2`` is located at ``r*(cos(q)*N.y + sin(q)*N.z)`` |
|
relative to ``pO``, where ``q(t)`` is a generalized coordinate |
|
specifying the angle rotated around the ``N.x`` axis according to the |
|
right-hand rule where ``N.y`` is zero. These positions can be set with: |
|
|
|
>>> q = dynamicsymbols('q') |
|
>>> p1.set_pos(pO, N.x + r*N.y) |
|
>>> p1.pos_from(pO) |
|
N.x + r*N.y |
|
>>> p2.set_pos(pO, r*(cos(q)*N.y + sin(q)*N.z).normalize()) |
|
>>> p2.pos_from(pO).simplify() |
|
r*cos(q(t))*N.y + r*sin(q(t))*N.z |
|
|
|
The geodesic length, which is in this case a is the hypotenuse of a |
|
right triangle where the other two side lengths are ``1`` (parallel to |
|
the cylinder's axis) and ``r*q(t)`` (parallel to the cylinder's cross |
|
section), can be calculated using the ``geodesic_length`` method: |
|
|
|
>>> cylinder.geodesic_length(p1, p2).simplify() |
|
sqrt(r**2*q(t)**2 + 1) |
|
|
|
If the ``geodesic_length`` method is passed an argument ``Point`` that |
|
doesn't lie on the sphere's surface then a ``ValueError`` is raised |
|
because it's not possible to calculate a value in this case. |
|
|
|
Parameters |
|
========== |
|
|
|
point_1 : Point |
|
Point from which the geodesic length should be calculated. |
|
point_2 : Point |
|
Point to which the geodesic length should be calculated. |
|
|
|
""" |
|
for point in (point_1, point_2): |
|
if not self.point_on_surface(point): |
|
msg = ( |
|
f'Geodesic length cannot be calculated as point {point} ' |
|
f'with radius {point.pos_from(self.point).magnitude()} ' |
|
f'from the cylinder\'s center {self.point} does not lie on ' |
|
f'the surface of {self} with radius {self.radius} and axis ' |
|
f'{self.axis}.' |
|
) |
|
raise ValueError(msg) |
|
|
|
relative_position = point_2.pos_from(point_1) |
|
parallel_length = relative_position.dot(self.axis) |
|
|
|
point_1_relative_position = point_1.pos_from(self.point) |
|
point_1_perpendicular_vector = ( |
|
point_1_relative_position |
|
- point_1_relative_position.dot(self.axis)*self.axis |
|
).normalize() |
|
|
|
point_2_relative_position = point_2.pos_from(self.point) |
|
point_2_perpendicular_vector = ( |
|
point_2_relative_position |
|
- point_2_relative_position.dot(self.axis)*self.axis |
|
).normalize() |
|
|
|
central_angle = _directional_atan( |
|
cancel(point_1_perpendicular_vector |
|
.cross(point_2_perpendicular_vector) |
|
.dot(self.axis)), |
|
cancel(point_1_perpendicular_vector.dot(point_2_perpendicular_vector)), |
|
) |
|
|
|
planar_arc_length = self.radius*central_angle |
|
geodesic_length = sqrt(parallel_length**2 + planar_arc_length**2) |
|
return geodesic_length |
|
|
|
def geodesic_end_vectors(self, point_1, point_2): |
|
"""The vectors parallel to the geodesic at the two end points. |
|
|
|
Parameters |
|
========== |
|
|
|
point_1 : Point |
|
The point from which the geodesic originates. |
|
point_2 : Point |
|
The point at which the geodesic terminates. |
|
|
|
""" |
|
point_1_from_origin_point = point_1.pos_from(self.point) |
|
point_2_from_origin_point = point_2.pos_from(self.point) |
|
|
|
if point_1_from_origin_point == point_2_from_origin_point: |
|
msg = ( |
|
f'Cannot compute geodesic end vectors for coincident points ' |
|
f'{point_1} and {point_2} as no geodesic exists.' |
|
) |
|
raise ValueError(msg) |
|
|
|
point_1_parallel = point_1_from_origin_point.dot(self.axis) * self.axis |
|
point_2_parallel = point_2_from_origin_point.dot(self.axis) * self.axis |
|
point_1_normal = (point_1_from_origin_point - point_1_parallel) |
|
point_2_normal = (point_2_from_origin_point - point_2_parallel) |
|
|
|
if point_1_normal == point_2_normal: |
|
point_1_perpendicular = Vector(0) |
|
point_2_perpendicular = Vector(0) |
|
else: |
|
point_1_perpendicular = self.axis.cross(point_1_normal).normalize() |
|
point_2_perpendicular = -self.axis.cross(point_2_normal).normalize() |
|
|
|
geodesic_length = self.geodesic_length(point_1, point_2) |
|
relative_position = point_2.pos_from(point_1) |
|
parallel_length = relative_position.dot(self.axis) |
|
planar_arc_length = sqrt(geodesic_length**2 - parallel_length**2) |
|
|
|
point_1_vector = ( |
|
planar_arc_length * point_1_perpendicular |
|
+ parallel_length * self.axis |
|
).normalize() |
|
point_2_vector = ( |
|
planar_arc_length * point_2_perpendicular |
|
- parallel_length * self.axis |
|
).normalize() |
|
|
|
return (point_1_vector, point_2_vector) |
|
|
|
def __repr__(self): |
|
"""Representation of a ``WrappingCylinder``.""" |
|
return ( |
|
f'{self.__class__.__name__}(radius={self.radius}, ' |
|
f'point={self.point}, axis={self.axis})' |
|
) |
|
|
|
|
|
def _directional_atan(numerator, denominator): |
|
"""Compute atan in a directional sense as required for geodesics. |
|
|
|
Explanation |
|
=========== |
|
|
|
To be able to control the direction of the geodesic length along the |
|
surface of a cylinder a dedicated arctangent function is needed that |
|
properly handles the directionality of different case. This function |
|
ensures that the central angle is always positive but shifting the case |
|
where ``atan2`` would return a negative angle to be centered around |
|
``2*pi``. |
|
|
|
Notes |
|
===== |
|
|
|
This function only handles very specific cases, i.e. the ones that are |
|
expected to be encountered when calculating symbolic geodesics on uniformly |
|
curved surfaces. As such, ``NotImplemented`` errors can be raised in many |
|
cases. This function is named with a leader underscore to indicate that it |
|
only aims to provide very specific functionality within the private scope |
|
of this module. |
|
|
|
""" |
|
|
|
if numerator.is_number and denominator.is_number: |
|
angle = atan2(numerator, denominator) |
|
if angle < 0: |
|
angle += 2 * pi |
|
elif numerator.is_number: |
|
msg = ( |
|
f'Cannot compute a directional atan when the numerator {numerator} ' |
|
f'is numeric and the denominator {denominator} is symbolic.' |
|
) |
|
raise NotImplementedError(msg) |
|
elif denominator.is_number: |
|
msg = ( |
|
f'Cannot compute a directional atan when the numerator {numerator} ' |
|
f'is symbolic and the denominator {denominator} is numeric.' |
|
) |
|
raise NotImplementedError(msg) |
|
else: |
|
ratio = sympify(trigsimp(numerator / denominator)) |
|
if isinstance(ratio, tan): |
|
angle = ratio.args[0] |
|
elif ( |
|
ratio.is_Mul |
|
and ratio.args[0] == Integer(-1) |
|
and isinstance(ratio.args[1], tan) |
|
): |
|
angle = 2 * pi - ratio.args[1].args[0] |
|
else: |
|
msg = f'Cannot compute a directional atan for the value {ratio}.' |
|
raise NotImplementedError(msg) |
|
|
|
return angle |
|
|