642 lines
21 KiB
Python
642 lines
21 KiB
Python
"""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
|