# coding=utf-8
"""Plane"""
from __future__ import division
from .pointvector import Point3D, Vector3D
from .ray import Ray3D
from ..intersection3d import intersect_line3d_plane, intersect_line3d_plane_infinite, \
intersect_plane_plane, closest_point3d_on_plane, \
closest_point3d_between_line3d_plane
from ..geometry2d.pointvector import Point2D, Vector2D
from ..geometry2d.ray import Ray2D
import math
[docs]
class Plane(object):
"""Plane object.
Args:
n: A Vector3D representing the normal of the plane.
o: A Point3D representing the origin point of the plane.
x: An optional Vector3D for the X-Axis of the Plane.
Note that this vector must be orthogonal to the input normal vector.
If None, the default will find an X-Axis in the world XY plane.
Properties:
* n
* o
* k
* x
* y
* tilt
* altitude
* azimuth
* min
* max
"""
__slots__ = ('_n', '_o', '_k', '_x', '_y', '_altitude', '_azimuth')
def __init__(self, n=Vector3D(0, 0, 1), o=Point3D(0, 0, 0), x=None):
"""Initialize Plane."""
assert isinstance(n, Vector3D), \
"Expected Vector3D for plane normal. Got {}.".format(type(n))
assert isinstance(o, Point3D), \
"Expected Point3D for plane origin. Got {}.".format(type(o))
self._n = n.normalize()
self._o = o
self._k = self._n.dot(self._o)
if x is None:
if self._n.x == 0 and self._n.y == 0:
self._x = Vector3D(1, 0, 0)
else:
x = Vector3D(self._n.y, -self._n.x, 0)
x = x.normalize()
self._x = x
else:
assert isinstance(x, Vector3D), \
"Expected Vector3D for plane X-axis. Got {}.".format(type(x))
x = x.normalize()
assert abs(self._n.x * x.x + self._n.y * x.y + self._n.z * x.z) < 1e-2, \
'Plane X-axis and normal vector are not orthogonal. Got angle of {} ' \
'degrees between them.'.format(math.degrees(self._n.angle(x)))
self._x = x
self._y = self._n.cross(self._x)
self._altitude = None
self._azimuth = None
[docs]
@classmethod
def from_dict(cls, data):
"""Create a Plane from a dictionary.
.. code-block:: python
{
"type": "Plane"
"n": (0, 0, 1),
"o": (0, 10, 0),
"x": (1, 0, 0)
}
"""
x = None
if 'x' in data and data['x'] is not None:
x = Vector3D.from_array(data['x'])
return cls(Vector3D.from_array(data['n']),
Point3D.from_array(data['o']), x)
[docs]
@classmethod
def from_three_points(cls, o, p2, p3):
"""Initialize a Plane from three Point3D objects that are not co-linear.
Args:
o: A Point3D representing the origin point of the plane.
p2: A Point3D representing a point the plane.
p3: A Point3D representing a point the plane.
"""
return cls((p2 - o).cross(p3 - o), o)
[docs]
@classmethod
def from_normal_k(cls, n, k):
"""Initialize a Plane from a normal vector and a scalar constant.
Args:
o: A Point3D representing the origin point of the plane.
k: Scalar constant relating origin point to normal vector
"""
# get an arbitrary point on the plane for the origin
if n.z:
o = Point3D(0., 0., k / n.z)
elif n.y:
o = Point3D(0., k / n.y, 0.)
else:
o = Point3D(k / n.x, 0., 0.)
return cls(n, o)
@property
def n(self):
"""Normal vector. This vector will always be normalized (magnitude = 1)."""
return self._n
@property
def o(self):
"""Origin point."""
return self._o
@property
def k(self):
"""Scalar constant relating origin point to normal vector."""
return self._k
@property
def x(self):
"""Plane X-Axis. This vector will always be normalized (magnitude = 1)."""
return self._x
@property
def y(self):
"""Plane Y-Axis. This vector will always be normalized (magnitude = 1)."""
return self._y
@property
def azimuth(self):
"""Get the azimuth of the plane.
This is always between 0, indicating the positive Y-axis, and moving clockwise
up to 2 * Pi, which indicates a return to the positive Y-axis.
This will be zero if the plane is perfectly horizontal.
"""
if self._azimuth is None:
try:
n_vec = Vector2D(0, 1)
self._azimuth = n_vec.angle_clockwise(Vector2D(self.n.x, self.n.y))
except ZeroDivisionError: # plane is perfectly horizontal
self._azimuth = 0
return self._azimuth
@property
def altitude(self):
"""Get the altitude of the plane. Between Pi/2 (up) and -Pi/2 (down)."""
if self._altitude is None:
self._altitude = self.n.angle(Vector3D(0, 0, -1)) - math.pi / 2
return self._altitude
@property
def tilt(self):
"""Get the tilt of the plane. Between 0 (up) and Pi (down)."""
return abs(self.altitude - (math.pi / 2))
@property
def min(self):
"""Returns the Plane origin."""
return self._o
@property
def max(self):
"""Returns the Plane origin."""
return self._o
[docs]
def flip(self):
"""Get a flipped version of this plane (facing the opposite direction)."""
return Plane(self.n.reverse(), self.o, self.x)
[docs]
def move(self, moving_vec):
"""Get a plane that has been moved along a vector.
Args:
moving_vec: A Vector3D with the direction and distance to move the plane.
"""
return Plane(self.n, self.o.move(moving_vec), self.x)
[docs]
def rotate(self, axis, angle, origin):
"""Rotate a plane by a certain angle around an axis and origin.
Right hand rule applies:
If axis has a positive orientation, rotation will be clockwise.
If axis has a negative orientation, rotation will be counterclockwise.
Args:
axis: A Vector3D axis representing the axis of rotation.
angle: An angle for rotation in radians.
origin: A Point3D for the origin around which the object will be rotated.
"""
return Plane(self.n.rotate(axis, angle),
self.o.rotate(axis, angle, origin),
self.x.rotate(axis, angle))
[docs]
def rotate_xy(self, angle, origin):
"""Get a plane rotated counterclockwise in the world XY plane by a certain angle.
Args:
angle: An angle in radians.
origin: A Point3D for the origin around which the object will be rotated.
"""
return Plane(self.n.rotate_xy(angle),
self.o.rotate_xy(angle, origin),
self.x.rotate_xy(angle))
[docs]
def reflect(self, normal, origin):
"""Get a plane reflected across a plane with the input normal vector and origin.
Args:
normal: A Vector3D representing the normal vector for the plane across
which the plane will be reflected. THIS VECTOR MUST BE NORMALIZED.
origin: A Point3D representing the origin from which to reflect.
"""
return Plane(self.n.reflect(normal),
self.o.reflect(normal, origin),
self.x.reflect(normal))
[docs]
def scale(self, factor, origin=None):
"""Scale a plane by a factor from an origin point.
Args:
factor: A number representing how much the plane should be scaled.
origin: A Point3D representing the origin from which to scale.
If None, it will be scaled from the World origin (0, 0, 0).
"""
return Plane(self.n, self.o.scale(factor, origin), self.x)
[docs]
def xyz_to_xy(self, point):
"""Get a Point2D in the coordinate system of this plane from a Point3D.
Note that the input Point3D should lie within this plane object in order
for the result to be valid.
"""
_diff = Vector3D(point.x - self.o.x, point.y - self.o.y, point.z - self.o.z)
return Point2D(self.x.dot(_diff), self.y.dot(_diff))
[docs]
def xy_to_xyz(self, point):
"""Get a Point3D from a Point2D in the coordinate system of this plane."""
# This method returns the same result as the following code:
# self.o + (self.x * point.x) + (self.y * point.y)
# It has been written explicitly to cut out the isinstance() checks for speed
_u = (self.x.x * point.x, self.x.y * point.x, self.x.z * point.x)
_v = (self.y.x * point.y, self.y.y * point.y, self.y.z * point.y)
return Point3D(
self.o.x + _u[0] + _v[0], self.o.y + _u[1] + _v[1], self.o.z + _u[2] + _v[2])
[docs]
def is_point_above(self, point):
"""Test if a given point is above or below this plane.
Above is defined as being on the side of the plane that the plane normal
is pointing towards.
Args:
point: A Point3D object to test.
Returns:
True is point is above; False if below.
"""
vec = Vector3D(point.x - self.o.x, point.y - self.o.y, point.z - self.o.z)
return self.n.dot(vec) > 0
[docs]
def closest_point(self, point):
"""Get the closest Point3D on this plane to another Point3D.
Args:
point: A Point3D object to which the closest point on this plane
will be computed.
Returns:
Point3D for the closest point on this plane to the input point.
"""
return closest_point3d_on_plane(point, self)
[docs]
def distance_to_point(self, point):
"""Get the minimum distance between this plane and the input point.
Args:
point: A Point3D object to which the minimum distance will be computed.
Returns:
The distance to the input point.
"""
close_pt = self.closest_point(point)
return point.distance_to_point(close_pt)
[docs]
def closest_points_between_line(self, line_ray):
"""Get the two closest Point3D between this plane and a Line3D or Ray3D.
Args:
line_ray: A Line3D or Ray3D object to which the closest points
will be computed.
Returns:
Two Point3D objects representing
1) The closest point on the input line_ray to this plane.
2) The closest point on this plane to the input line_ray.
Will be None if the line_ray intersects this plant
"""
return closest_point3d_between_line3d_plane(line_ray, self)
[docs]
def distance_to_line(self, line_ray):
"""Get the minimum distance between this plane and the input Line3D or Ray3D.
Args:
line_ray: A Line3D or Ray3D object to which the minimum distance
will be computed.
Returns:
The minimum distance to the input line_ray.
"""
result = self.closest_points_between_line(line_ray)
if result is None: # intersection
return 0
else:
return result[0].distance_to_point(result[1])
[docs]
def project_point(self, point, projection_direction=None):
"""Project a point onto this Plane given a certain projection direction.
Args:
point: A Point3D to be projected onto the plane
projection_direction: A Line3D or Ray3D object to set the direction
of projection. If None, this Plane's normal will be
used. (Default: None).
Returns:
Point3D for the projected point. Will be None if the projection_direction
is parallel to the plane.
"""
int_ray = Ray3D(point, self.n) if projection_direction is None \
else Ray3D(point, projection_direction)
return intersect_line3d_plane_infinite(int_ray, self)
[docs]
def intersect_line_ray(self, line_ray):
"""Get the intersection between this plane and the input Line3D or Ray3D.
Args:
line_ray: A Line3D or Ray3D object for which intersection will be computed.
Returns:
Point3D for the intersection. Will be None if no intersection exists.
"""
return intersect_line3d_plane(line_ray, self)
[docs]
def intersect_arc(self, arc):
"""Get the intersection between this Plane and an Arc3D.
Args:
plane: A Plane object for which intersection will be computed.
Returns:
A list of 2 Point3D objects if a full intersection exists.
A list with a single Point3D object if the line is tangent or intersects
only once. None if no intersection exists.
"""
_plane_int_ray = self.intersect_plane(arc.plane)
if _plane_int_ray is not None:
_p12d = arc.plane.xyz_to_xy(_plane_int_ray.p)
_p22d = arc.plane.xyz_to_xy(_plane_int_ray.p + _plane_int_ray.v)
_v2d = _p22d - _p12d
_int_ray2d = Ray2D(_p12d, _v2d)
_int_pt2d = arc.arc2d.intersect_line_infinite(_int_ray2d)
if _int_pt2d is not None:
return [arc.plane.xy_to_xyz(pt) for pt in _int_pt2d]
return None
[docs]
def intersect_plane(self, plane):
"""Get the intersection between this Plane and another Plane.
Args:
plane: A Plane object for which intersection will be computed.
Returns:
Ray3D for the intersection. Will be None if planes are parallel.
"""
result = intersect_plane_plane(self, plane)
if result is not None:
return Ray3D(result[0], result[1])
return None
[docs]
def is_coplanar(self, plane):
"""Test if another Plane object is perfectly coplanar with this Plane.
Args:
plane: A Plane object for which co-planarity will be tested.
Returns:
True if plane is coplanar. False if it is not coplanar.
"""
if self.n == plane.n:
return self.k == plane.k
elif self.n == plane.n.reverse():
return self.k == -plane.k
return False
[docs]
def is_coplanar_tolerance(self, plane, tolerance, angle_tolerance):
"""Test if another Plane object is coplanar within a certain tolerance.
Args:
plane: A Plane object for which co-planarity will be tested.
tolerance: The distance between the two planes at which point they can
be considered coplanar.
angle_tolerance: The angle in radians that the plane normals can
differ from one another in order for the planes to be considered
coplanar.
Returns:
True if plane is coplanar. False if it is not coplanar.
"""
if self.n.angle(plane.n) <= angle_tolerance or \
self.n.angle(plane.n.reverse()) <= angle_tolerance:
return self.distance_to_point(plane.o) <= tolerance
return False
[docs]
def duplicate(self):
"""Get a copy of this object."""
return self.__copy__()
[docs]
def to_dict(self):
"""Get Plane as a dictionary."""
return {'type': 'Plane',
'n': self.n.to_array(),
'o': self.o.to_array(),
'x': self.x.to_array()}
def __copy__(self):
return Plane(self.n, self.o, self.x)
def __key(self):
"""A tuple based on the object properties, useful for hashing."""
return (self.n, self.o, self.x)
def __hash__(self):
return hash(self.__key())
def __eq__(self, other):
return isinstance(other, Plane) and self.__key() == other.__key()
def __ne__(self, other):
return not self.__eq__(other)
[docs]
def ToString(self):
"""Overwrite .NET ToString."""
return self.__repr__()
def __repr__(self):
return 'Plane (<%.2f, %.2f, %.2f> normal) (<%.2f, %.2f, %.2f> origin)' % \
(self.n.x, self.n.y, self.n.z, self.o.x, self.o.y, self.o.z)