city_retrofit/venv/lib/python3.7/site-packages/pyny3d/geoms.py

2504 lines
91 KiB
Python

# -*- coding: utf-8 -*-
import numpy as np
class root(object):
"""
Lowest geometry class in hierarchy. Actually do nothig but store
two general methods for the real classes:
* :func:`plot`
* :func:`get_centroid`
* :func:`copy`
* :func:`save`
* :func:`restore`
Other Global methods (but individually defined in each class) are:
* get_seed
* seed2pyny
* get_domain
"""
def __init__(self):
self.backup = None
def plot(self, color='default', ret=False, ax=None):
"""
Generates a basic 3D visualization.
:param color: Polygons color.
:type color: matplotlib color, 'default' or 't' (transparent)
:param ret: If True, returns the figure. It can be used to add
more elements to the plot or to modify it.
:type ret: bool
:param ax: If a matplotlib axes given, this method will
represent the plot on top of this axes. This is used to
represent multiple plots from multiple geometries,
overlapping them recursively.
:type ax: mplot3d.Axes3D, None
:returns: None, axes
:rtype: mplot3d.Axes3D, bool
"""
import matplotlib.pylab as plt
import mpl_toolkits.mplot3d as mplot3d
# Bypass a plot
if color == False:
if ax is None: ax = mplot3d.Axes3D(fig=plt.figure())
return ax
# Clone and extract the information from the object
obj = self.__class__(**self.get_seed())
plotable3d = obj.get_plotable3d()
# Domain
domain = obj.get_domain()
bound = np.max(domain[1]-domain[0])
centroid = obj.get_centroid()
pos = np.vstack((centroid-bound/2, centroid+bound/2))
# Cascade plot?
if ax is None: # Non cascade
ax = mplot3d.Axes3D(fig=plt.figure())
else:
old_pos = np.array([ax.get_xbound(),
ax.get_ybound(),
ax.get_zbound()]).T
pos = np.dstack((pos, old_pos))
pos = np.array([np.min(pos[0, :, :], axis=1),
np.max(pos[1, :, :], axis=1)])
# Plot
if color == 'default': color = 't'
if color == 't': color = (0,0,0,0)
for polygon in plotable3d:
polygon.set_facecolor(color)
polygon.set_edgecolor('k')
ax.add_collection3d(polygon)
# Axis limits
ax.set_xlim3d(left=pos[0,0], right=pos[1,0])
ax.set_ylim3d(bottom=pos[0,1], top=pos[1,1])
ax.set_zlim3d(bottom=pos[0,2], top=pos[1,2])
if ret: return ax
def center_plot(self, ax):
"""
Centers and keep the aspect ratio in a 3D representation.
Created to help higher classes to manage cascade representation
of multiple lower objects.
:param ax: Axes to apply the method.
:type ax: mplot3d.Axes3D
:returns: None
"""
# Domain
domain = self.get_domain()
bound = np.max(domain[1]-domain[0])
centroid = self.get_centroid()
pos = np.vstack((centroid-bound/2, centroid+bound/2))
# Axis limits
ax.set_xlim3d(left=pos[0,0], right=pos[1,0])
ax.set_ylim3d(bottom=pos[0,1], top=pos[1,1])
ax.set_zlim3d(bottom=pos[0,2], top=pos[1,2])
def get_centroid(self):
"""
The centroid is considered the center point of the circunscribed
paralellepiped, not the mass center.
:returns: (x, y, z) coordinates of the centroid of the object.
:rtype: ndarray
"""
return self.get_domain().mean(axis=0)
def copy(self):
"""
:returns: A deepcopy the entire instance.
:rtype: ``pyny3d`` object
.. seealso:: :func:`save`, :func:`restore`
"""
import copy
return self.__class__(**copy.deepcopy(self.get_seed()))
def save(self):
"""
Saves a deepcopy of the current state the instance.
``.restore()`` method will return this copy.
:returns: None
.. seealso:: :func:`restore`, :func:`copy`
"""
self.backup = self.copy()
def restore(self):
"""
Load a previous saved state of the current object. ``.save()``
method can be used any time to save the current state of an
object.
:returns: Last saved version of this object.
:rtype: ``pyny3d`` object
.. seealso:: :func:`save`, :func:`copy`
"""
if self.backup is not None:
return self.backup
else:
raise ValueError('No backup previously saved.')
class Polygon(root):
"""
The most basic geometry class. It generates and stores all the
information relative to a 3D polygon.
Instances of this class work as iterable object. When indexed,
returns the points which conform it.
:param points: Sorted points which form the polygon (xyz or xy).
Do not repeat the first point at the end.
:type points: ndarray *shape=(N, 2 or 3)*
:param check_convexity: If True, an error will be raised for
concave Polygons. It is a requirement of the code that the
polygons have to be convex.
:type check_convexity: bool
:returns: None
.. note:: This object can be locked (``.lock()`` method) in order to
precompute information for faster further computations.
"""
verify = True
def __init__(self, points, make_ccw=True, **kwargs):
# Input errors
if type(points) != np.ndarray:
raise ValueError('pyny3d.Polygon needs a ndarray as input')
# Adapt 2D/3D
if points.shape[1] == 2:
from pyny3d.utils import arange_col
points = np.hstack((points, arange_col(points.shape[0])*0))
elif points.shape[1] != 3:
raise ValueError('pyny3d.Polygon needs 2 or 3 coords '+\
'(columns) at least')
if make_ccw and Polygon.verify: points = Polygon.make_ccw(points)
# Basic processing
self.points = points
# Optional processing
self.path = None
self.parametric = None
self.shapely = None
# Parameters
self.locked = False
self.domain = None
self.area = None
def __iter__(self): return iter(self.points)
def __getitem__(self, key): return self.points[key]
def lock(self):
"""
Precomputes some parameters to run faster specific methods like
Surface.classify.
Stores ``self.domain`` and ``self.path``, both very used in
the shadows simulation, in order to avoid later unnecessary
calculations and verifications.
:returns: None
.. warning:: Unnecessary locks can slowdown your code.
"""
if not self.locked:
self.path = self.get_path()
self.domain = self.get_domain()
self.locked = True
def seed2pyny(self, seed):
"""
Re-initialize an object with a seed.
:returns: A new ``pyny.Polygon``
:rtype: ``pyny.Polygon``
"""
# import geoms as pyny
return Polygon(**seed)
@staticmethod
def is_convex(points):
"""
Static method. Returns True if the polygon is convex regardless
of whether its vertices follow a clockwise or a
counter-clockwise order. This is a requirement for the rest of
the program.
:param points: Points intented to form a polygon.
:type points: ndarray with points xyz in rows
:returns: Whether a polygon is convex or not.
:rtype: bool
.. note:: Despite the code works for ccw polygons, in order to
avoid possible bugs it is always recommended to use ccw
rather than cw.
.. warning:: This method do not check the order of the points.
"""
# Verification based on the cross product
n_points = points.shape[0]
i=-1
u = points[i] - points[i-1]
v = points[i+1] - points[i]
last = np.sign(np.round(np.cross(u, v)))
while i < n_points-1:
u = points[i] - points[i-1]
v = points[i+1] - points[i]
s = np.sign(np.round(np.cross(u, v)))
if abs((s - last).max()) > 1:
return False
last = s
i += 2
return True
@staticmethod
def make_ccw(points):
"""
Static method. Returns a counterclock wise ordered sequence of
points. If there are any repeated point, the method will raise
an error.
Due to the 3D character of the package, the order or the points
will be tried following this order:
1. z=0 pprojection
2. x=0 pprojection
3. y=0 pprojection
:param points: Points to form a polygon (xyz or xy)
:type points: ndarray with points (xyz or xy) in rows
:returns: ccw version of the points.
:rtype: ndarray (shape=(N, 2 or 3))
"""
from scipy.spatial import ConvexHull
from pyny3d.utils import sort_numpy
# Repeated points
points_aux = sort_numpy(points)
check = np.sum(np.abs(np.diff(points_aux, axis=0)), axis=1)
if check.min() == 0: raise ValueError('Repeated point: \n'+str(points))
# Convexity
hull = None
for cols in [(0, 1), (1, 2), (0, 2)]:
try:
hull = ConvexHull(points[:, cols])
except:
pass
if hull is not None: return points[hull.vertices]
if hull is None: raise ValueError('Wrong polygon: \n'+str(points))
def to_2d(self):
"""
Generates the real 2D polygon of the 3D polygon. This method
performs a change of reference system obtaining the same polygon
but with the new z=0 plane containing the polygon.
This library mostly uses the z=0 projection to perform
operations with the polygons. For this reason, if real 2D
planar operations are required (like calculate real area) the
best way is to create a new ``pyny.Polygon`` with this method.
:returns: Planar orthogonal view of the polygon.
:rtype: ``pyny.Polygon``
"""
# New reference system
a = self[1]-self[0]
a = a/np.linalg.norm(a) # arbitrary first axis
n = np.cross(a, self[-1]-self[0])
n = n/np.linalg.norm(n) # normal axis
b = -np.cross(a, n) # Orthogonal to the others
# Reference system change
R_inv = np.linalg.inv(np.array([a, b, n])).T
real = np.dot(R_inv, self.points.T).T
real[np.isclose(real, 0)] = 0
return Polygon(real[:, :2])
def contains(self, points, edge=True):
"""
Point-in-Polygon algorithm for multiple points for the z=0
projection of the ``pyny.Polygon``.
:param points: Set of points to evaluate.
:type points: ndarray with points (xyz or xy) in rows
:param edge: If True, consider the points in the Polygon's edge
as inside the Polygon.
:type edge: bool
:returns: Whether each point is inside the polygon or not (in
z=0 projection).
:rtype: ndarray (dtype=bool)
"""
radius = 1e-10 if edge else -1e-10
return self.get_path().contains_points(points[:, :2],
radius=radius)
def get_parametric(self, check=True, tolerance=0.001):
"""
Calculates the parametric equation of the plane that contains
the polygon. The output has the form np.array([a, b, c, d])
for:
.. math::
a*x + b*y + c*z + d = 0
:param check: Checks whether the points are actually
in the same plane with certain *tolerance*.
:type check: bool
:param tolerance: Tolerance to check whether the points belong
to the same plane.
:type tolerance: float
.. note:: This method automatically stores the solution in order
to do not repeat calculations if the user needs to call it
more than once.
"""
if self.parametric is None:
# Plane calculation
a, b, c = np.cross(self.points[2,:]-self.points[0,:],
self.points[1,:]-self.points[0,:])
d = -np.dot(np.array([a, b, c]), self.points[2, :])
self.parametric = np.array([a, b, c, d])
# Point belonging verification
if check:
if self.points.shape[0] > 3:
if np.min(np.abs(self.points[3:,0]*a+
self.points[3:,1]*b+
self.points[3:,2]*c+
d)) > tolerance:
raise ValueError('Polygon not plane: \n'+\
str(self.points))
return self.parametric
def get_path(self):
"""
:returns: matplotlib.path.Path object for the z=0 projection of
this polygon.
"""
if self.path == None:
from matplotlib import path
return path.Path(self.points[:, :2]) # z=0 projection!
return self.path
def get_shapely(self):
"""
:returns: shapely.Polygon object of the z=0 projection of
this polygon.
"""
if self.shapely == None:
from shapely.geometry import Polygon as shPolygon
self.shapely = shPolygon(self.points[:, :2]) # z=0 projection!
return self.shapely
def get_domain(self):
"""
:returns: opposite vertices of the bounding prism for this
object.
:rtype: ndarray([min], [max])
"""
if self.domain is None:
return np.array([self.points.min(axis=0),
self.points.max(axis=0)])
return self.domain
def get_area(self):
"""
:returns: The area of the polygon.
"""
if self.area is None:
self.area = self.to_2d().get_shapely().area
return self.area
def get_height(self, points, only_in = True, edge=True, full=False):
"""
Given a set of points, it computes the z value for the
parametric equation of the plane where the polygon belongs.
Only the two first columns of the points will be taken into
account as x and y.
By default, the points outside the object will have a NaN value
in the z column. If the inputed points has a third column the z
values outside the Surface's domain will remain unchanged, the
rest will be replaced.
:param points: Coordinates of the points to calculate.
:type points: ndarray shape=(N, 2 or 3)
:param only_in: If True, computes only the points which are
inside of the Polygon.
:type only_in: bool
:param edge: If True, consider the points in the Polygon's edge
inside the Polygon.
:type edge: bool
:param full: If true, the return will have three columns
(x, y, z) instead of one (z).
:type full: bool
:returns: (z) or (x, y, z)
:rtype: ndarray shape=(N, 1 or 3)
"""
p = self.get_parametric()
z = (-p[0]*points[:, 0]-p[1]*points[:, 1]-p[3])/p[2]
if only_in:
pip = self.contains(points, edge=edge)
z[pip == False] *= np.nan
if full:
z = np.hstack((points[:, :2],
np.reshape(z, (points.shape[0], 1))))
if points.shape[1] == 3: # Restore original z
z[pip == False] = points[pip == False]
return z
def get_seed(self):
"""
Collects the required information to generate a data estructure
that can be used to recreate exactly the same geometry object
via *\*\*kwargs*.
:returns: Object's sufficient info to initialize it.
:rtype: dict
"""
return {'points': self.points}
def get_plotable3d(self):
"""
:returns: matplotlib Poly3DCollection
:rtype: mpl_toolkits.mplot3d
"""
import mpl_toolkits.mplot3d as mplot3d
return [mplot3d.art3d.Poly3DCollection([self.points])]
def pip(self, points, sorted_col=0, radius=0):
"""
Point-in-Polygon for the z=0 projection. This function enhances
the performance of ``Polygon.contains()`` by verifying only the
points which are inside the bounding box of the polygon. To do
it fast, it needs the points array to be already sorted by one
column.
:param points: list of *(x, y, z) or (x, y)* coordinates of the
points to check. (The z value will not be taken into
account).
:type points: ndarray (shape=(N, 2 or 3))
:param sorted_col: Index of the sorted column (0 or 1).
:type sorted_col: int
:param radius: Enlarge Polygons domain by a specified quantity.
:type radius: float
:returns: Which points are inside the polygon.
:rtype: ndarray (dtpye=bool)
.. warning:: By default pip considers that the set of points is
currently sorted by the first column.
.. warning:: This method only works if the polygon has been
locked (:func:`lock`).
"""
xy = points[:, :2]
n_points = xy.shape[0]
index = np.arange(n_points, dtype = int)
b = self.domain
b[0] = b[0] - radius
b[1] = b[1] + radius
# Slicing the sorted column
k = np.searchsorted(xy[:, sorted_col],
(b[0, sorted_col], b[1, sorted_col]+1e-10))
xy = xy[k[0]:k[1]]
index = index[k[0]:k[1]]
# solution
k = index[self.path.contains_points(xy, radius=radius)]
sol = np.zeros(n_points, dtype=bool)
sol[k] = True
return sol
def plot2d(self, color='default', alpha=1, ret=True):
"""
Generates a 2D plot for the z=0 Polygon projection.
:param color: Polygon color.
:type color: matplotlib color
:param alpha: Opacity.
:type alpha: float
:param ret: If True, returns the figure. It can be used to add
more elements to the plot or to modify it.
:type ret: bool
:returns: None, axes
:rtype: None, matplotlib axes
"""
import matplotlib.pyplot as plt
import matplotlib.patches as patches
path = self.get_path()
domain = self.get_domain()[:, :2]
if color is 'default': color = 'b'
# Plot
fig = plt.figure()
ax = fig.add_subplot(111)
ax.add_patch(patches.PathPatch(path, facecolor=color, lw=1,
edgecolor='k', alpha=alpha))
ax.set_xlim(domain[0,0],domain[1,0])
ax.set_ylim(domain[0,1], domain[1,1])
if ret: return ax
def move(self, d_xyz):
"""
Translate the Polygons in x, y and z coordinates.
:param d_xyz: displacement in x, y(, and z).
:type d_xyz: tuple (len=2 or 3)
:returns: ``pyny.Polygon``
"""
space = Space(Place(Surface(self)))
return space.move(d_xyz, inplace=False)[0].surface[0]
def rotate(self, angle, direction='z', axis=None):
"""
Returns a new Polygon which is the same but rotated about a
given axis.
If the axis given is ``None``, the rotation will be computed
about the Surface's centroid.
:param angle: Rotation angle (in radians)
:type angle: float
:param direction: Axis direction ('x', 'y' or 'z')
:type direction: str
:param axis: Point in z=0 to perform as rotation axis
:type axis: tuple (len=2 or 3) or None
:returns: ``pyny.Polygon``
"""
space = Space(Place(Surface(self)))
return space.rotate(angle, direction, axis)[0].surface[0]
def mirror(self, axes='x'):
"""
Generates a symmetry of the Polygon respect global axes.
:param axes: 'x', 'y', 'z', 'xy', 'xz', 'yz'...
:type axes: str
:returns: ``pyny.Polygon``
"""
space = Space(Place(Surface(self)))
return space.mirror(axes, inplace=False)[0].surface[0]
def matrix(self, x=(0, 0), y=(0, 0) , z=(0, 0)):
"""
Copy the ``pyny.Polygon`` along a 3D matrix given by the
three tuples x, y, z:
:param x: Number of copies and distance between them in this
direction.
:type x: tuple (len=2)
:returns: list of ``pyny.Polygons``
"""
space = Space(Place(Surface(self)))
space = space.matrix(x, y, z, inplace=False)
return [place.surface[0] for place in space]
class Surface(root):
"""
This class groups contiguous polygons (coplanars or not). These
polygons cannot overlap each other on the z=0 projection\*.
This object is a composition of polygons and holes. The polygons can
be used to "hold up" other objects (points, other polygons...) and
to compute shadows. The holes exist only to prevent the program
to place objects on them. The shadows computation do not take care
of the holes\*\*, instead, they can be emulated by a collection of
polygons.
Instances of this class work as iterable object. When indexed,
returns the ``pyny.Polygons`` which conform it.
:param polygons: Polygons to be set as Surface. This is the only
necessary input to create a Surface.
:type polygons: list of ndarray, list of ``pyny.Polygon``
:param holes: Polygons to be set as holes of the Surface.
:type holes: list of ndarray, list of ``pyny.Polygon``
:param make_ccw: If True, points will be sorted ccw for each
polygon.
:type make_ccw: bool
:param melt: If True, the :func:`melt` method will be launched at
initialization.
:type melt: bool
:param check_contiguity: If True, :func:`contiguous` will be
launched at initialization.
:type check_contiguity: bool
:returns: None
.. note:: \* For models with planes stacked in column, use
the Place class to distinct them. For example, a three-storey
building structure can be modeled by using one ``pyny.Place``
for storey where the floor is a Surface and the columns are
Polyhedra.
.. note:: \*\* In the future versions of this library it will
simulate shadows through the holes.
"""
def __init__(self, polygons, holes=[], make_ccw=True,
melt=False, check_contiguity=False, **kwargs):
# Always works with lists
if type(polygons) != list: polygons = [polygons]
if type(holes) != list: holes = [holes]
# Creating the object
## Polygons
if type(polygons[0]) == np.ndarray:
self.polygons = [Polygon(polygon, make_ccw)
for polygon in polygons]
elif type(polygons[0]) == Polygon:
self.polygons = polygons
else:
raise ValueError('pyny3d.Surface needs a ndarray or '+\
'pyny3d.Polygons as input')
### Check contiguity
if check_contiguity:
if not Surface.contiguous(self.polygons):
raise ValueError('Non-contiguous polygons in the Surface')
## Holes
if len(holes) > 0:
if type(holes[0]) == np.ndarray:
self.holes = [Polygon(hole, make_ccw)
for hole in holes]
elif type(holes[0]) == Polygon:
self.holes = holes
else:
self.holes = []
if melt: self.melt()
def __iter__(self): return iter(self.polygons)
def __getitem__(self, key): return self.polygons[key]
def lock(self):
"""
Lock the Polygons in the Surface to run faster specific methods
like Surface.classify.
:returns: None
"""
for polygon in self.polygons: polygon.lock()
def seed2pyny(self, seed):
"""
Re-initialize an object with a seed.
:returns: A new ``pyny.Surface``
:rtype: ``pyny.Surface``
"""
# import geoms as pyny
return Surface(**seed)
def classify(self, points, edge=True, col=1, already_sorted=False):
"""
Calculates the belonging relationship between the polygons
in the Surface and a set of points.
This function enhances the performance of ``Polygon.contains()``
when used with multiple non-overlapping polygons (stored in a
Surface) by verifying only the points which are inside the z=0
bounding box of each polygon. To do it fast, it sorts the points
and then apply ``Polygon.pip()`` for each Polygon.
:param points: list of (x, y, z) or (x, y) coordinates of the
points to check. (The z value will not be taken into
account).
:type points: ndarray (shape=(N, 2 or 3))
:param edge: If True, consider the points in a Polygon's edge
inside a Polygon.
:type edge: bool
:param col: Column to sort or already sorted.
:type col: int
:param already_sorted: If True, the method will consider that
the *points* are already sorted by the column *col*.
:type already_sorted: bool
:returns: Index of the Polygon to which each point belongs.
-1 if outside the Surface.
:rtype: ndarray (dtpye=int)
"""
xy = points[:, :2].copy()
radius = 1e-10 if edge else 0
n_points = xy.shape[0]
self.lock() # Precomputes polygons information
# Sorting the points
if not already_sorted:
from pyny3d.utils import sort_numpy
# Sorting by the y column can be faster if set_mesh has been
# used to generate the points.
points, order_back = sort_numpy(xy, col, order_back=True)
# Computing PiP
sol = np.ones(n_points, dtype=int)*-1
for i, polygon in enumerate(self):
pip = polygon.pip(points, sorted_col=col, radius=radius)
sol[pip] = i
# Return
if not already_sorted:
return sol[order_back]
else:
return sol
def intersect_with(self, polygon):
"""
Calculates the intersection between the polygons in this surface
and other polygon, in the z=0 projection.
This method rely on the ``shapely.Polygon.intersects()`` method.
The way this method is used is intersecting this polygon
recursively with all identified polygons which overlaps with it
in the z=0 projection.
:param polygon: Polygon to intersect with the Surface.
:type polygon: pyny.Polygon
:returns: Multiple polygons product of the intersections.
:rtype: dict of ndarrays (keys are the number of the polygon
inside the surface)
"""
intersections = {}
for i, poly in enumerate(self):
if polygon.get_shapely().intersects(poly.get_shapely()):
inter = polygon.get_shapely().intersection(poly.get_shapely())
intersections[i] = np.array(list(inter.exterior.coords))[:-1]
return intersections
def get_plotable3d(self):
"""
:returns: matplotlib Poly3DCollection
:rtype: list of mpl_toolkits.mplot3d
"""
return [polygon.get_plotable3d()[0] for polygon in self]
def get_domain(self):
"""
:returns: opposite vertices of the bounding prism for this
object in the form of ndarray([min], [max])
.. note:: This method automatically stores the solution in order
to do not repeat calculations if the user needs to call it
more than once.
"""
points = ([poly.points for poly in self]+
[holes.points for holes in self.holes])
points = np.concatenate(points, axis=0)
return np.array([points.min(axis=0), points.max(axis=0)])
def get_seed(self):
"""
Collects the required information to generate a data estructure
that can be used to recreate exactly the same geometry object
via *\*\*kwargs*.
:returns: Object's sufficient info to initialize it.
:rtype: dict
"""
return {'polygons': [poly.points for poly in self],
'holes': [hole.points for hole in self.holes]}
def get_height(self, points, edge=True):
"""
Given a set of points, computes the z value for the parametric
equation of the Polygons in the Surface.
This method computes recursively the ``Polygon.get_height()``
method for all the Polygons in the Surface, obtaining the z
value for the points according to the local Polygon they belong.
The points outside the object will have a NaN value in the
z column. If the inputed points has a third column the z values
outside the Surface's domain will remain unchanged, the rest
will be replaced.
:param points: list of coordinates of the points to calculate.
:type points: ndarray (shape=(N, 2 or 3))
:param edge: If True, consider the points in the Polygon's edge
inside the Polygon.
:type edge: bool
:returns: (x, y, z) arrays
:rtype: ndarray (shape=(N, 3))
"""
for poly in self:
points = poly.get_height(points, edge=edge, full=True)
for hole in self.holes:
pip = hole.contains(points, edge=True)
points[pip, 2] = np.nan
return points
def add_holes(self, holes_list, make_ccw=True):
"""
Add holes to the holes list.
:param holes_list: Polygons that will be treated as holes.
:type holes_list: list or pyny.Polygon
:param make_ccw: If True, points will be sorted ccw.
:type make_ccw: bool
:returns: None
.. note:: The holes can be anywhere, not necesarily on the
surface.
"""
if type(holes_list) != list: holes_list = [holes_list]
self.holes += [Polygon(hole, make_ccw) for hole in holes_list]
def melt(self, plot=False):
"""
Find and merge groups of polygons in the surface that meet the
following criteria:
* Are coplanars.
* Are contiguous.
* The result is convex.
This method is very useful at reducing the number the items and,
therefore, the shadowing time computing. Before override this
instance, it is saved and can be restored with ``.restore()``
:param plot: If True, generates the before and after
visualizations for the surface. Use it to check the results.
:type plot: bool
:returns: None
.. warning:: This method do not check if the merged polygons are
actually convex. The convex hull of the union is directly
calculated. For this reason, it is very important to visualy
check the solution.
"""
from pyny3d.utils import bool2index
from scipy.spatial import ConvexHull
# First, coplanarity
## Normalize parametric equations
para = [poly.get_parametric() for poly in self]
para = np.array([p/np.linalg.norm(p) for p in para])
n = para.shape[0]
## Coincidences
cop = []
for i, plane in enumerate(para[:-1]):
indexes = np.zeros((n-i-1, 4))
for c in range(4):
indexes[:, c] = np.isclose(para[i+1:, c], plane[c])
pos = bool2index(indexes.sum(axis=1)==4)+i+1
if pos.shape[0] > 0:
cop.append(np.hstack((i, pos)))
para[pos, :] = np.nan
# Second, contiguity
substituted = []
cop_cont = []
for i, group in enumerate(cop):
polygons = [self[i] for i in group]
if Surface.contiguous(polygons):
cop_cont.append(polygons)
substituted.append(group)
if len(substituted) != 0:
self.save()
if plot: self.plot()
substituted = sum(substituted)
# Hull
merged = []
for polygons in cop_cont:
points = np.concatenate([polygon.points
for polygon in polygons])
hull = ConvexHull(points[:, :2])
merged.append(Polygon(points[hull.vertices]))
# Final substitution
new_surface = [self[i] for i in range(len(self.polygons))
if i not in substituted]
new_surface += merged
self.polygons = new_surface
self.sorted_areas = None
if plot: self.plot()
def get_area(self):
"""
:returns: The area of the surface.
.. warning:: The area is computed as the sum of the areas of all
the polygons minus the sum of the areas of all the holes.
"""
polys = sum([polygon.get_area() for polygon in self])
holes = sum([hole.get_area() for hole in self.holes])
return polys-holes
@staticmethod
def contiguous(polygons):
"""
Static method. Check whether a set of convex polygons are all
contiguous. Two polygons are considered contiguous if they
share, at least, one side (two vertices).
This is not a complete verification, it is a very simplified
one. For a given set of polygons this method will verify that
the number of common vertices among them equals or exceeds the
minimum number of common vertices possible.
This little algorithm will not declare a contiguous set of
polygons as non-contiguous, but it can fail in the reverse for
certain geometries where polygons have several common vertices
among them.
:param polygons: List of polygons.
:type polygons: list of ``pyny.Polygon``
:return: Whether tey are contiguous.
:rtype: bool
"""
from pyny3d.utils import sort_numpy
n = len(polygons)
points = sort_numpy(np.concatenate([polygon.points
for polygon in polygons]))
diff = np.sum(np.diff(points, axis=0), axis=1)
if sum(np.isclose(diff, 0)) < n*2-2:
return False
else:
return True
def plot2d(self, c_poly='default', alpha=1, cmap='default', ret=False,
title=' ', colorbar=False, cbar_label=''):
"""
Generates a 2D plot for the z=0 Surface projection.
:param c_poly: Polygons color.
:type c_poly: matplotlib color
:param alpha: Opacity.
:type alpha: float
:param cmap: colormap
:type cmap: matplotlib.cm
:param ret: If True, returns the figure. It can be used to add
more elements to the plot or to modify it.
:type ret: bool
:param title: Figure title.
:type title: str
:param colorbar: If True, inserts a colorbar in the figure.
:type colorbar: bool
:param cbar_label: Colorbar right label.
:type cbar_label: str
:returns: None, axes
:rtype: None, matplotlib axes
"""
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import matplotlib.cm as cm
paths = [polygon.get_path() for polygon in self]
domain = self.get_domain()[:, :2]
# Color
if type(c_poly) == str: # Unicolor
if c_poly is 'default': c_poly = 'b'
color_vector = c_poly*len(paths)
colorbar = False
else: # Colormap
if cmap is 'default':
cmap = cm.YlOrRd
import matplotlib.colors as mcolors
normalize = mcolors.Normalize(vmin=c_poly.min(), vmax=c_poly.max())
color_vector = cmap(normalize(c_poly))
# Plot
fig = plt.figure(title)
ax = fig.add_subplot(111)
for p, c in zip(paths, color_vector):
ax.add_patch(patches.PathPatch(p, facecolor=c, lw=1,
edgecolor='k', alpha=alpha))
ax.set_xlim(domain[0,0],domain[1,0])
ax.set_ylim(domain[0,1], domain[1,1])
# Colorbar
if colorbar:
scalarmappaple = cm.ScalarMappable(norm=normalize, cmap=cmap)
scalarmappaple.set_array(c_poly)
cbar = plt.colorbar(scalarmappaple, shrink=0.8, aspect=10)
cbar.ax.set_ylabel(cbar_label, rotation=0)
if ret: return ax
def iplot(self, c_poly='default', c_holes='c', ret=False, ax=None):
"""
Improved plot that allows to plot polygons and holes in
different colors.
:param c_poly: Polygons color.
:type c_poly: matplotlib color, 'default' or 't' (transparent)
:param c_holes: Holes color.
:type c_holes: matplotlib color, 'default' or 't' (transparent)
:param ret: If True, returns the figure. It can be used to add
more elements to the plot or to modify it.
:type ret: bool
:param ax: If a matplotlib axes given, this method will
represent the plot on top of this axes. This is used to
represent multiple plots from multiple geometries,
overlapping them recursively.
:type ax: mplot3d.Axes3D, None
:returns: None, axes
:rtype: None, mplot3d.Axes3D
"""
# Default parameter
if c_holes == 'default': c_holes = 'c' # cyan for the holes
seed = self.get_seed()
if c_poly != False:
ax = Surface(seed['polygons']).plot(color=c_poly, ret=True,
ax=ax)
if self.holes != [] and c_holes != False:
ax = Surface(seed['holes']).plot(color=c_holes, ret=True,
ax=ax)
if ret: return ax
def move(self, d_xyz):
"""
Translate the Surface in x, y and z coordinates.
:param d_xyz: displacement in x, y(, and z).
:type d_xyz: tuple (len=2 or 3)
:returns: ``pyny.Surface``
"""
return Space(Place(self)).move(d_xyz, inplace=False)[0].surface
def rotate(self, angle, direction='z', axis=None):
"""
Returns a new Surface which is the same but rotated about a
given axis.
If the axis given is ``None``, the rotation will be computed
about the Surface's centroid.
:param angle: Rotation angle (in radians)
:type angle: float
:param direction: Axis direction ('x', 'y' or 'z')
:type direction: str
:param axis: Point in z=0 to perform as rotation axis
:type axis: tuple (len=2 or 3) or None
:returns: ``pyny.Surface``
"""
return Space(Place(self)).rotate(angle, direction, axis)[0].surface
def mirror(self, axes='x'):
"""
Generates a symmetry of the Surface respect global axes.
:param axes: 'x', 'y', 'z', 'xy', 'xz', 'yz'...
:type axes: str
:returns: ``pyny.Surface``
"""
return Space(Place(self)).mirror(axes, inplace=False)[0].surface
def matrix(self, x=(0, 0), y=(0, 0) , z=(0, 0)):
"""
Copy the ``pyny.Surface`` along a 3D matrix given by the
three tuples x, y, z:
:param x: Number of copies and distance between them in this
direction.
:type x: tuple (len=2)
:returns: list of ``pyny.Surface``
"""
space = Space(Place(self)).matrix(x, y, z, inplace=False)
return [place.surface for place in space]
class Polyhedron(root):
"""
Represents 3D polygon-based convex polyhedra.
Under the hood, ``pyny.Polyhedron`` class uses the ``pyny.Surface``
infrastructure to store and operate with the faces (Polygons). This
``pyny.Surface`` can be found in ``Polyhedron.aux_surface``.
Instances of this class work as iterable object. When indexed,
returns the ``pyny.Polygons`` which conform it.
:param polygons: Polygons to be set as the Polyhedron. These
Polygons have to be contiguous and form a closed polyhedron\*.
:type polygons: list of ndarray, list of ``pyny.Polygon``
:param make_ccw: If True, points will be sorted ccw for each
polygon.
:type make_ccw: bool
:returns: None
.. note:: \* A concave or open polyhedron will not produce any
error and the code will probably work fine but it is important
to keep in mind that *pyny3d* was created to work specifically
with convex and closed bodies and you will probably get errors
later in other parts of the code.
.. warning:: This object do NOT check the contiguity of the
polygons or whether the polyhedron is closed or not, even it is
actually a requirement.
"""
def __init__(self, polygons, make_ccw=True, **kwargs):
self.aux_surface = Surface(polygons, make_ccw=make_ccw)
self.polygons = self.aux_surface.polygons
def __iter__(self): return iter(self.polygons)
def __getitem__(self, key): return self.polygons[key]
def seed2pyny(self, seed):
"""
Re-initialize an object with a seed.
:returns: A new ``pyny.Polyhedron``
:rtype: ``pyny.Polyhedron``
"""
return Polyhedron(**seed)
@staticmethod
def by_two_polygons(poly1, poly2, make_ccw=True):
"""
Static method. Creates a closed ``pyny.Polyhedron`` connecting
two polygons. Both polygons must have the same number of
vertices. The faces of the Polyhedron created have to be planar,
otherwise, an error will be raised.
The Polyhedron will have the *poly1* and *poly2* as "top" and
"bottom" and the rest of its faces will be generated by matching
the polygons' vertices in twos.
:param poly1: Origin polygon
:type poly1: ``pyny.Polygon`` or ndarray (shape=(N, 3))
:param poly2: Destination polygon
:type poly2: ``pyny.Polygon`` or ndarray (shape=(N, 3))
:param make_ccw: If True, points will be sorted ccw for each
polygon.
:type make_ccw: bool
:returns: Polyhedron
:rtype: ``pypy.Polyhedron``
.. warning:: If an error is raised, probably the Polyhedron
have non-planar faces.
.. warning:: If the Polyhedra are not created with this method
or ``Place.add_extruded_obstacles()``, holes will not be
added.
"""
if type(poly1) == Polygon:
poly1 = poly1.points
poly2 = poly2.points
vertices = np.dstack((poly1, poly2))
polygons = []
for i in np.arange(vertices.shape[0])-1:
polygons.append(np.array([vertices[i, :, 1],
vertices[i+1,:, 1],
vertices[i+1, :, 0],
vertices[i,:, 0]]))
polygons.append(poly1)
polygons.append(poly2)
return Polyhedron(polygons, make_ccw=make_ccw)
def get_seed(self):
"""
Collects the required information to generate a data estructure
that can be used to recreate exactly the same geometry object
via *\*\*kwargs*.
:returns: Object's sufficient info to initialize it.
:rtype: dict
"""
return {'polygons': self.aux_surface.get_seed()['polygons']}
def get_plotable3d(self):
"""
:returns: matplotlib Poly3DCollection
:rtype: list of mpl_toolkits.mplot3d
"""
return self.aux_surface.get_plotable3d()
def get_domain(self):
"""
:returns: opposite vertices of the bounding prism for this
object.
:rtype: ndarray([min], [max])
.. note:: This method automatically stores the solution in order
to do not repeat calculations if the user needs to call it
more than once.
"""
return self.aux_surface.get_domain()
def get_area(self):
"""
:returns: The area of the polyhedron.
"""
return sum([polygon.get_area() for polygon in self.aux_surface])
def move(self, d_xyz):
"""
Translate the Polyhedron in x, y and z coordinates.
:param d_xyz: displacement in x, y(, and z).
:type d_xyz: tuple (len=2 or 3)
:returns: ``pyny.Polyhedron``
"""
polygon = np.array([[0,0], [0,1], [1,1], [0,1]])
space = Space(Place(polygon, polyhedra=self))
return space.move(d_xyz, inplace=False)[0].polyhedra[0]
def rotate(self, angle, direction='z', axis=None):
"""
Returns a new Polyhedron which is the same but rotated about a
given axis.
If the axis given is ``None``, the rotation will be computed
about the Polyhedron's centroid.
:param angle: Rotation angle (in radians)
:type angle: float
:param direction: Axis direction ('x', 'y' or 'z')
:type direction: str
:param axis: Point in z=0 to perform as rotation axis
:type axis: tuple (len=2 or 3) or None
:returns: ``pyny.Polyhedron``
"""
polygon = np.array([[0,0], [0,1], [1,1]])
space = Space(Place(polygon, polyhedra=self))
return space.rotate(angle, direction, axis)[0].polyhedra[0]
def mirror(self, axes='x'):
"""
Generates a symmetry of the Polyhedron respect global axes.
:param axes: 'x', 'y', 'z', 'xy', 'xz', 'yz'...
:type axes: str
:returns: ``pyny.Polyhedron``
"""
polygon = np.array([[0,0], [0,1], [1,1]])
space = Space(Place(polygon, polyhedra=self))
return space.mirror(axes, inplace=False)[0].polyhedra[0]
def matrix(self, x=(0, 0), y=(0, 0) , z=(0, 0)):
"""
Copy the ``pyny.Polyhedron`` along a 3D matrix given by the
three tuples x, y, z:
:param x: Number of copies and distance between them in this
direction.
:type x: tuple (len=2)
:returns: list of ``pyny.Polyhedron``
"""
polygon = np.array([[0,0], [0,1], [1,1]])
space = Space(Place(polygon, polyhedra=self))
space = space.matrix(x, y, z, inplace=False)
return [place.polyhedra[0] for place in space]
class Place(root):
"""
Aggregates one ``pyny.Surface``, one Set of points and an indefinite
number of ``pyny.Polyhedra``.
Represents the union of a surface with an unlimited number of
obstacles. All the elements that conform a Place keep their
integrity and functionality, what the Place class makes is to give
the possibility to perform higher level operations in these groups
of objects.
Instances of this class cannot work as iterable object and cannot be
indexed.
The lower level instances will be stored in:
* **Place.surface**
* **Place.polyhedra**
* **Place.set_of_points**
:param surface: This is the only necessary input to create a
``pyny.Place``.
:type surface: ``pyny.Surface``, list of ``pyny.Polygon`` or list
of ndarray
:param polyhedra: ``pyny.Polyhedra`` to attach to the
``pyny.Place``.
:type polyhedra: list of ``pyny.Polyhedra``
:param set_of_points: Points to attach to the ``pyny.Place``.
:type set_of_points: ndarray (shape=(N, 3))
:param make_ccw: If True, points will be sorted ccw for each
polygon.
:type make_ccw: bool
:returns: None
.. note:: This object is implemented to be used dynamically. Once
created, it is possible to add elements, with
``.add_set_of_points``, ``.add_extruded_obstacles`` among others,
without replace it.
"""
def __init__(self, surface, polyhedra=[], set_of_points=np.empty((0, 3)),
make_ccw=True, melt=False, **kwargs):
# Creating the object
## Surface
if type(surface) == Surface: # Surface object
self.surface = surface
elif type(surface) == dict: # Seed
self.surface = Surface(**surface)
elif type(surface) == list or type(surface) == np.ndarray: # Simple input
self.surface = Surface(**{'polygons': surface,
'make_ccw': make_ccw,
'melt': melt})
else:
raise ValueError('pyny3d.Place needs a dict or pyny3d.Surface as input')
## Polyhedra
if polyhedra != []:
if type(polyhedra) != list: polyhedra = [polyhedra]
if type(polyhedra[0]) == Polyhedron:
self.polyhedra = polyhedra
else:
self.polyhedra = [Polyhedron(polyhedron, make_ccw)
for polyhedron in polyhedra]
else:
self.polyhedra = []
## Set of points
if type(set_of_points) == np.ndarray:
if set_of_points.shape[1] == 3:
self.set_of_points = set_of_points
else:
raise ValueError('pyny3d.Place has an invalid set_of_points as input')
def seed2pyny(self, seed):
"""
Re-initialize an object with a seed.
:returns: A new ``pyny.Place``
:rtype: ``pyny.Place``
"""
# import geoms as pyny
return Place(**seed)
def add_set_of_points(self, points):
"""
Add a new set of points to the existing one without removing it.
:param points: Points to be added.
:type points: ndarray (shape=(N, 3))
:returns: None
"""
self.set_of_points = np.concatenate((self.set_of_points, points))
def get_domain(self):
"""
:returns: opposite vertices of the bounding prism for this
object.
:rtype: ndarray([min], [max])
"""
if self.polyhedra != []:
polyhedras_domain = np.vstack([poly.get_domain()
for poly in self.polyhedra])
else:
polyhedras_domain = np.ones((0, 3))
points = np.vstack((self.surface.get_domain(),
polyhedras_domain,
self.set_of_points))
return np.array([points.min(axis=0), points.max(axis=0)])
def get_height(self, points, edge=True, attach=False,
extra_height=0):
"""
Launch ``pyny.Surface.get_height(points)`` for the Place's
Surface.
This method gives the possibility to store the computed points
along with the Place's set of points. It also makes possible to
add an extra height (z value) to these points.
The points outside the object will have a NaN value in the
z column. These point will not be stored but it will be
returned.
:param points: list of coordinates of the points to calculate.
:type points: ndarray (shape=(N, 2 or 3))
:param edge: If True, consider the points in the Polygon's edge
inside the Polygon.
:type edge: bool
:param attach: If True, stores the computed points along with
the Place's set of points.
:type attach: bool
:param extra_height: Adds an extra height (z value) to the
resulting points.
:type extra_height: float
:returns: (x, y, z)
:rtype: ndarray
"""
points = self.surface.get_height(points, edge=edge)
points[:, 2] += extra_height
if attach:
logic = np.logical_not(np.isnan(points[:, 2]))
self.add_set_of_points(points[logic])
else:
return points
def mesh(self, mesh_size=1, extra_height=0.1, edge=True, attach=True):
"""
Generates a set of points distributed in a mesh that covers the
whole Place and computes their height.
Generates a xy mesh with a given mesh_size in the
Place.surface's domain and computes the Surface's height for the
nodes. This mesh is alligned with the main directions `x` and
`y`.
:param mesh_size: distance between points.
:type mesh_size: float
:param extra_height: Adds an extra height (z value) to the
resulting points.
:type extra_height: float
:param edge: If True, consider the points in the Polygon's edge
inside the Polygon.
:type edge: bool
:param attach: If True, stores the computed points along with
the Place's set of points.
:type attach: bool
:returns: (x, y, z)
:rtype: ndarray
"""
# Mesh
a, b = self.get_domain()
a -= 2*mesh_size # extra bound
b += 2*mesh_size
x_mesh = np.arange(a[0], b[0], mesh_size)
y_mesh = np.arange(a[1], b[1], mesh_size)
x, y = np.meshgrid(x_mesh, y_mesh)
xy = np.array([x.ravel(), y.ravel()]).T
# Compute and store
xyz = self.get_height(xy, edge=edge, attach=attach,
extra_height=extra_height)
if not attach: return xyz
def clear_set_of_points(self):
"""
Remove all the points in the Place.
"""
self.set_of_points = np.empty((0, 3))
def add_holes(self, holes_list, make_ccw=True):
"""
Add holes to the Place's ``pyny.Surface``.
:param holes_list: Polygons that will be treated as holes.
:type holes_list: list or ``pyny.Polygon``
:param make_ccw: If True, points will be sorted ccw.
:type make_ccw: bool
:returns: None
.. note:: The holes can be anywhere, not necesarily on the
surface.
"""
self.surface.add_holes(holes_list, make_ccw=make_ccw)
def add_extruded_obstacles(self, top_polys, make_ccw=True):
"""
Add polyhedras to the Place by giving their top polygon and
applying extrusion along the z axis. The resulting polygon
from the intersection will be declared as a hole in the Surface.
:param top_polys: Polygons to be extruded to the Surface.
:type top_polys: list of ``pyny.Polygon``
:param make_ccw: If True, points will be sorted ccw.
:type make_ccw: bool
:returns: None
.. note:: When a top polygon is projected and it
instersects multiple Surface's polygons, a independent
polyhedron will be created for each individual
intersection\*.
.. warning:: The top polygons have to be over the Surface, that
is, their z=0 projection have to be inside of Surface's z=0
projection.
.. warning:: If the Polyhedra are not created with this method
or ``Polyhedron.by_two_polygons()``, holes will not be
added.
"""
if type(top_polys) != list: top_polys = [top_polys]
for poly1 in top_polys:
if type(poly1) != Polygon:
obstacle = Polygon(poly1, make_ccw)
intersections_dict = self.surface.intersect_with(obstacle)
base = []
for i, xy in intersections_dict.items():
base.append(self.surface[i].get_height(xy, full=True))
base_surf = Surface(base)
base_surf.melt()
for base_poly in base_surf:
obst_points = obstacle.get_height(base_poly.points,
full=True)
self.surface.holes.append(base_poly)
self.polyhedra.append(Polyhedron.by_two_polygons(
base_poly.points,
obst_points,
make_ccw))
def get_seed(self):
"""
Collects the required information to generate a data estructure
that can be used to recreate exactly the same geometry object
via *\*\*kwargs*.
:returns: Object's sufficient info to initialize it.
:rtype: dict
"""
seed = {}
seed['surface'] = self.surface.get_seed()
polyhedra = [polyhedron.get_seed()['polygons']
for polyhedron in self.polyhedra]
if not polyhedra: polyhedra = []
seed['polyhedra'] = polyhedra
if self.set_of_points.shape[0] != 0:
seed['set_of_points'] = self.set_of_points
else:
seed['set_of_points'] = np.empty((0, 3))
return seed
def get_plotable3d(self):
"""
:returns: matplotlib Poly3DCollection
:rtype: list of mpl_toolkits.mplot3d
"""
polyhedra = sum([polyhedron.get_plotable3d()
for polyhedron in self.polyhedra], [])
return polyhedra + self.surface.get_plotable3d()
def iplot(self, c_poly='default', c_holes='default', c_sop='r',
s_sop=25, extra_height=0, ret=False, ax=None):
"""
Improved plot that allows to plot polygons and holes in
different colors and to change the size and the color of the
set of points.
The points can be plotted accordingly to a ndarray colormap.
:param c_poly: Polygons color.
:type c_poly: matplotlib color, 'default' or 't' (transparent)
:param c_holes: Holes color.
:type c_holes: matplotlib color, 'default' or 't' (transparent)
:param c_sop: Set of points color.
:type c_sop: matplotlib color or colormap
:param s_sop: Set of points size.
:type s_sop: float or ndarray
:param extra_height: Elevates the points in the visualization.
:type extra_height: float
:param ret: If True, returns the figure. It can be used to add
more elements to the plot or to modify it.
:type ret: bool
:param ax: If a matplotlib axes given, this method will
represent the plot on top of this axes. This is used to
represent multiple plots from multiple geometries,
overlapping them recursively.
:type ax: mplot3d.Axes3D, None
:returns: None, axes
:rtype: None, mplot3d.Axes3D
"""
ax = self.surface.iplot(c_poly=c_poly, c_holes=c_holes,
ret=True, ax=ax)
for polyhedron in self.polyhedra:
ax = polyhedron.plot(color=c_poly, ret=True, ax=ax)
if c_sop != False:
p = self.set_of_points
ax.scatter(p[:, 0], p[:, 1], p[:, 2]+extra_height,
c=c_sop, s=s_sop)
self.center_plot(ax)
if ret: return ax
def move(self, d_xyz):
"""
Translate the Place in x, y and z coordinates.
:param d_xyz: displacement in x, y(, and z).
:type d_xyz: tuple (len=2 or 3)
:returns: ``pyny.Place``
"""
return Space(self).move(d_xyz, inplace=False)[0]
def rotate(self, angle, direction='z', axis=None):
"""
Returns a new Place which is the same but rotated about a
given axis.
If the axis given is ``None``, the rotation will be computed
about the Place's centroid.
:param angle: Rotation angle (in radians)
:type angle: float
:param direction: Axis direction ('x', 'y' or 'z')
:type direction: str
:param axis: Point in z=0 to perform as rotation axis
:type axis: tuple (len=2 or 3) or None
:returns: ``pyny.Place``
"""
return Space(self).rotate(angle, direction, axis)[0]
def mirror(self, axes='x'):
"""
Generates a symmetry of the Place respect global axes.
:param axes: 'x', 'y', 'z', 'xy', 'xz', 'yz'...
:type axes: str
:returns: ``pyny.Place``
"""
return Space(self).mirror(axes, inplace=False)[0]
def matrix(self, x=(0, 0), y=(0, 0) , z=(0, 0)):
"""
Copy the ``pyny.Place`` along a 3D matrix given by the
three tuples x, y, z:
:param x: Number of copies and distance between them in this
direction.
:type x: tuple (len=2)
:returns: list of ``pyny.Place``
"""
space = Space(self).matrix(x, y, z, inplace=False)
return [place for place in space]
class Space(root):
"""
the highest level geometry class. It Aggregates ``pyny.Places`` to
group computations. It can be initialized empty.
The lower level instances will be stored in:
* **Space.places**
:param places: Places or empty list.
:type places: list of ``pyny.Place``
:returns: None
Instances of this class work as iterable object. When indexed,
returns the ``pyny.Places`` which conform it.
.. note:: This class is implemented to be used dynamically. Once
created, it is possible to add elements, with
``.add_places``, ``.add_spaces`` among others, without replace
it.
.. warning:: Although it is a dynamic class, it is recommended to
use the methods to manipulate it. Editing the internal
attributes or methods directly can result in a bad behavior.
"""
def __init__(self, places=[], **kwargs):
# Empty initializations
self.places = []
# Lock attributes
self.locked = False
self.map = None
self.seed = None
self.map2seed_schedule = None
self.explode_map_schedule = None
# Creating the object
if places != []:
if type(places) != list: places = [places]
if type(places[0]) == Place: # Places already initialized
self.add_places(places)
elif type(places[0]) == dict: # Initialize the places
self.add_places([ Place(**place) for place in places ])
else:
raise ValueError('pyny3d.Space needs a list, dict or '+\
'pyny3d.Place as input')
def __iter__(self): return iter(self.places)
def __getitem__(self, key): return self.places[key]
def lock(self):
"""
Precomputes some parameters to run faster specific methods like
Surface.classify. This method is automatically launched before shadows
computation.
:returns: None
"""
if self.locked: return
from pyny3d.utils import bool2index
# seed
self.seed = self.get_seed()
# map
self.map = self.get_map()
# map2polygons schedule
m2p = [[], [], 0] # [polygons, holes, sop]
index, points = self.get_map()
## points
bool_1 = index[:, 1] == -1
m2p[2] = bool2index(bool_1)
index_1 = bool2index(np.logical_not(bool_1)) # remain
index = index[index_1]
## new index
index_bool = np.diff(index[:, 2]*1e12
+index[:, 1]*1e8
+index[:, 0]*1e4) != 0
## Dissemination loop
dif = np.arange(index_bool.shape[0], dtype=int)[index_bool]+1
dif = np.append(dif, index_bool.shape[0]+1)
i = 0
for j in dif:
if index[i, 2] < 0: # hole
m2p[1].append(index_1[np.arange(i, j)])
if index[i, 2] >= 0: # polygon
m2p[0].append(index_1[np.arange(i, j)])
i = j
self.explode_map_schedule = m2p
# Sort areas
areas = []
for poly in self.explode()[0]:
areas.append(Polygon(poly, False).get_area())
self.sorted_areas = np.argsort(np.array(areas))[::-1]
# Lock
self.locked = True
def seed2pyny(self, seed):
"""
Re-initialize an object with a seed.
:returns: A new ``pyny.Space``
:rtype: ``pyny.Space``
.. seealso::
* :func:`get_seed`
* :func:`get_map`
* :func:`map2seed`
* :func:`explode_map`
"""
return Space(**seed)
def add_places(self, places, ret=False):
"""
Add ``pyny.Places`` to the current space.
:param places: Places to add.
:type places: list of pyny.Place
:param ret: If True, returns the whole updated Space.
:type ret: bool
:returns: None, ``pyny.Space``
.. warning:: This method acts inplace.
"""
if type(places) != list: places = [places]
self.places += places
if ret: return self
def add_spaces(self, spaces, ret=False):
"""
Add ``pyny.Spaces`` to the current space. In other words, it
merges multiple ``pyny.Spaces`` in this instance.
:param places: ``pyny.Spaces`` to add.
:type places: list of pyny.Spaces
:param ret: If True, returns the whole updated Space.
:type ret: bool
:returns: None, ``pyny.Space``
.. warning:: This method acts inplace.
"""
if type(spaces) != list: spaces = [spaces]
Space.add_places(self, sum([space.places for space in spaces], []))
if ret: return self
def get_domain(self):
"""
:returns: opposite vertices of the bounding prism for this
object.
:rtype: ndarray([min], [max])
"""
points = np.vstack([place.get_domain() for place in self])
return np.array([points.min(axis=0), points.max(axis=0)])
def get_seed(self):
"""
Collects the required information to generate a data estructure
that can be used to recreate exactly the same geometry object
via *\*\*kwargs*.
:returns: Object's sufficient info to initialize it.
:rtype: dict
.. seealso::
* :func:`get_map`
* :func:`map2pyny`
* :func:`map2seed`
* :func:`explode_map`
"""
self.seed = {'places': [place.get_seed() for place in self]}
return self.seed
def get_plotable3d(self):
"""
:returns: matplotlib Poly3DCollection
:rtype: list of mpl_toolkits.mplot3d
"""
return sum([place.get_plotable3d() for place in self], [])
def get_sets_of_points(self):
"""
Collects all the sets of points for the Places contained in the
Space.
:returns: An array with the points of all ``pyny.Places`` which
form this ``pyny.Space``.
:rtype: ndarray (shape=(N, 3))
"""
return np.concatenate([place.set_of_points for place in self])
def get_sets_index(self):
"""
Returns a one dimension array with the Place where the points
belong.
:returns: The ``pyny.Place`` where the points belong.
:rtype: list of int
"""
index = []
for i, place in enumerate(self):
index.append(np.ones(place.set_of_points.shape[0])*i)
return np.concatenate(index).astype(int)
def get_polygons(self):
"""
Collects all polygons for the Places in the Space.
:returns: The polygons which form the whole ``pyny.Space``.
:rtype: list of ``pyny.Polygon``
"""
return np.concatenate([place.set_of_points for place in self])
def clear_sets_of_points(self):
"""
Clears all the sets of points of each ``pyny.Place`` in the
Space.
"""
for place in self: place.set_of_points = np.ones((0, 3))
def get_map(self):
"""
Collects all the points coordinates from this ``pyny.Space``
instance.
In order to keep the reference, it returns an index with the
following key:
* The first column is the Place.
* The second column is the body (-1: points, 0: surface,
n: polyhedron)
* The third column is the polygon (-n: holes)
* The fourth column is the point.
:returns: [index, points]
:rtype: list of ndarray
.. note:: This method automatically stores the solution in order
to do not repeat calculations if the user needs to call it
more than once.
.. seealso::
* :func:`get_seed`
* :func:`map2pyny`
* :func:`map2seed`
* :func:`explode_map`
"""
seed = self.get_seed()['places'] # template
points = []
index = []
for i, place in enumerate(seed):
# Set of points [_, -1, 0, _]
n_points = place['set_of_points'].shape[0]
if n_points != 0: # It can be False (no set_of_points)
points.append(place['set_of_points'])
index.append(np.vstack((np.tile(np.array([[i], [-1], [0]]),
n_points),
np.arange(n_points))))
#Holes [_, 0, -N, _]
for ii, hole in enumerate(place['surface']['holes']):
n_points = hole.shape[0]
points.append(hole)
index.append(np.vstack((np.tile(np.array([[i], [0], [-ii-1]]),
n_points),
np.arange(n_points))))
#Surface [_, 0, N, _]
for ii, polygon in enumerate(place['surface']['polygons']):
n_points = polygon.shape[0]
points.append(polygon)
index.append(np.vstack((np.tile(np.array([[i], [0], [ii]]),
n_points),
np.arange(n_points))))
#Polyhedras [_, N, _, _]
if len(place['polyhedra']) != 0: # It can be False (no obstacles)
for iii, polygon_list in enumerate(place['polyhedra']):
for iv, polygon in enumerate(polygon_list):
n_points = polygon.shape[0]
points.append(polygon)
index.append(np.vstack((np.tile(np.array([[i], [1+iii],
[iv]]), n_points),
np.arange(n_points))))
index = np.concatenate(index, axis=1).T
points = np.concatenate(points)
self.map = [index, points]
return self.map
def map2seed(self, map_):
"""
Returns a seed from an altered map. The map needs to have the
structure of this ``pyny.Space``, that is, the same as
``self.get_map()``.
:param map_: the points, and the same order, that appear at
``pyny.Space.get_map()``.
:type map_: ndarray (shape=(N, 3))
:returns: ``pyny.Space`` seed.
:rtype: dict
.. seealso::
* :func:`get_seed`
* :func:`get_map`
* :func:`map2pyny`
* :func:`explode_map`
"""
seed = self.get_seed()['places'] # Template
o = 0
for i, place in enumerate(seed):
# Set of points [_, -1, 0, _]
if place['set_of_points'].shape[0] != 0: # Maybe no set_of_points
polygon = place['set_of_points']
seed[i]['set_of_points'] = map_[o: o + polygon.shape[0], :]
o += polygon.shape[0]
#Holes [_, 0, -N, _]
for ii, hole in enumerate(place['surface']['holes']):
seed[i]['surface']['holes'][ii] = map_[o: o + hole.shape[0], :]
o += hole.shape[0]
#Surface [_, 0, N, _]
for ii, polygon in enumerate(place['surface']['polygons']):
seed[i]['surface']['polygons'][ii] = map_[o: o +
polygon.shape[0], :]
o += polygon.shape[0]
#Polyhedras [_, N, _, _]
if len(place['polyhedra']) != 0: # Maybe no polyhedra
for ii, polygon_list in enumerate(place['polyhedra']):
for iii, polygon in enumerate(polygon_list):
seed[i]['polyhedra'][ii][iii] = map_[o: o +
polygon.shape[0], :]
o += polygon.shape[0]
return {'places': seed}
def map2pyny(self, map_):
"""
Returns a different version of this ``pyny.Space`` using an
altered map.
:param map_: the points, and the same order, that appear at
``pyny.Space.get_map()``.
:type map_: ndarray (shape=(N, 3))
:returns: ``pyny.Space``
.. seealso::
* :func:`get_seed`
* :func:`get_map`
* :func:`map2seed`
* :func:`explode_map`
"""
return self.seed2pyny(self.map2seed(map_))
def explode(self):
"""
Collects all the polygons, holes and points in the Space
packaged in a list. The returned geometries are not in *pyny3d*
form, instead the will be represented as *ndarrays*.
:returns: The polygons, the holes and the points.
:rtype: list
"""
seed = self.get_seed()['places']
points = []
polygons = []
holes = []
for place in seed:
points.append(place['set_of_points'])
polygons += sum(place['polyhedra'], [])
polygons += place['surface']['polygons']
holes += place['surface']['holes']
return [polygons, holes, np.concatenate(points, axis=0)]
def explode_map(self, map_):
"""
Much faster version of ``pyny.Space.explode()`` method for
previously locked ``pyny.Space``.
:param map_: the points, and the same order, that appear at
``pyny.Space.get_map()``. There is no need for the index if
locked.
:type map_: ndarray (shape=(N, 3))
:returns: The polygons, the holes and the points.
:rtype: list
.. seealso::
* :func:`get_seed`
* :func:`get_map`
* :func:`map2pyny`
* :func:`map2seed`
"""
if self.explode_map_schedule is None:
index = map_[0]
points = map_[1]
# points
k = index[:, 1] == -1
sop = points[k] # Set of points
index = index[np.logical_not(k)]
points = points[np.logical_not(k)]
# new index
index_bool = np.diff(index[:, 2]*1e12
+index[:, 1]*1e8
+index[:, 2]*1e4).astype(bool)
# Dissemination loop
polygons = []
holes = []
dif = np.arange(index_bool.shape[0], dtype=int)[index_bool]+1
dif = np.append(dif, index_bool.shape[0]+1)
i = 0
for j in dif:
if index[i, 2] < 0: # hole
holes.append(points[i:j, :])
if index[i, 2] >= 0: # polygon
polygons.append(points[i:j, :])
i = j
return [polygons, holes, sop]
else:
# Only points (without index) allowed
if type(map_) == list:
points = map_[1]
else:
points = map_
ex = self.explode_map_schedule
polygons = [ points[p ,:] for p in ex[0] ]
holes = [ points[p ,:] for p in ex[1] ]
sop = points[ex[2] ,:]
return [polygons, holes, sop]
def get_height(self, points, edge=True, attach=False, extra_height=0):
"""
Launch ``pyny.Place.get_height(points)`` recursively for all
the ``pyny.Place``.
The points outside the object will have a NaN value in the
z column. These point will not be stored but it will be
returned.
:param points: list of coordinates of the points to calculate.
:type points: ndarray (shape=(N, 2 or 3))
:param edge: If True, consider the points in the Polygon's edge
inside the Polygon.
:type edge: bool
:param attach: If True, stores the computed points along with
the Place's set of points.
:type attach: bool
:param extra_height: Adds an extra height (z value) to the
resulting points.
:type extra_height: float
:returns: (x, y, z)
:rtype: ndarray
"""
for place in self:
points = place.get_height(points, edge, attach, extra_height)
if not attach: return points
def mesh(self, mesh_size=1, extra_height=0.1, edge=True, attach=True):
"""
Launch `pyny.Place.mesh(points)` recursively for all
the ``pyny.Place`` individually.
:param mesh_size: distance between points.
:type mesh_size: float
:param extra_height: Adds an extra height (z value) to the
resulting points.
:type extra_height: float
:param edge: If True, consider the points in the Polygon's edge
inside the Polygon.
:type edge: bool
:param attach: If True, stores the computed points along with
the Place's set of points.
:type attach: bool
:returns: (x, y, z)
:rtype: ndarray
"""
for place in self:
place.mesh(mesh_size, extra_height, edge, attach)
def move(self, d_xyz, inplace=False):
"""
Translate the whole Space in x, y and z coordinates.
:param d_xyz: displacement in x, y(, and z).
:type d_xyz: tuple (len=2 or 3)
:param inplace: If True, the moved ``pyny.Space`` is copied and
added to the current ``pyny.Space``. If False, it returns
the new ``pyny.Space``.
:type inplace: bool
:returns: None, ``pyny.Space``
"""
state = Polygon.verify
Polygon.verify = False
if len(d_xyz) == 2: d_xyz = (d_xyz[0], d_xyz[1], 0)
xyz = np.array(d_xyz)
# Add (dx, dy, dz) to all the coordinates
map_ = self.get_map()[1] + xyz
space = self.map2pyny(map_)
Polygon.verify = state
if inplace:
self.add_spaces(space)
return None
else:
return space
def rotate(self, angle, direction='z', axis=None):
"""
Returns a new Space which is the same but rotated about a
given axis.
If the axis given is ``None``, the rotation will be computed
about the Space's centroid.
:param angle: Rotation angle (in radians)
:type angle: float
:param direction: Axis direction ('x', 'y' or 'z')
:type direction: str
:param axis: Point in z=0 to perform as rotation axis
:type axis: tuple (len=2 or 3) or None
:returns: ``pyny.Space``
"""
state = Polygon.verify
Polygon.verify = False
if axis is None:
axis = self.get_centroid()
else:
if len(axis) == 2: axis = np.array([axis[0], axis[1], 0])
map_ = self.get_map()[1] - axis
## Rotation matrix
c = np.cos(angle)
s = np.sin(angle)
if direction == 'z':
R = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
elif direction == 'y':
R = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]])
elif direction == 'x':
R = np.array([[1, 0, 0], [0, c, -s], [0, s, c]])
rotated_ = np.dot(R, map_.T).T
map_ = rotated_ + axis
space = self.map2pyny(map_)
Polygon.verify = state
return space
def matrix(self, x=(0, 0), y=(0, 0) , z=(0, 0), inplace=True):
"""
Copy the ``pyny.Space`` along a 3D matrix given by the three
tuples x, y, z:
:param x: Number of copies and distance between them in this
direction.
:type x: tuple (len=2)
:param inplace: If True, the moved ``pyny.Space`` is copied and
added to the current ``pyny.Space``. If False, it returns
the new ``pyny.Space``.
:type inplace: bool
:returns: None, ``pyny.Space``
"""
state = Polygon.verify
Polygon.verify = False
base = False
if x[0] != 0:
x = (np.ones(x[0]-1)*x[1]).cumsum()
if not base: base = self.copy()
single = self.copy()
for pos in x:
base.add_spaces(single.move((pos, 0, 0), inplace=False))
if y[0] != 0:
y = (np.ones(y[0]-1)*y[1]).cumsum()
if not base: base = self.copy()
single = base.copy()
for pos in y:
base.add_spaces(single.move((0, pos, 0), inplace=False))
if z[0] != 0:
z = (np.ones(z[0]-1)*z[1]).cumsum()
if not base: base = self.copy()
single = base.copy()
for pos in z:
base.add_spaces(single.move((0, 0, pos), inplace=False))
Polygon.verify = state
if inplace:
self.places = []
self.add_spaces(base)
else:
return base
def mirror(self, axes='x', inplace=False):
"""
Generates a symmetry of the Space respect global axes.
:param axes: 'x', 'y', 'z', 'xy', 'xz', 'yz'...
:type axes: str
:param inplace: If True, the new ``pyny.Space`` is copied and
added to the current ``pyny.Space``. If False, it returns
the new ``pyny.Space``.
:type inplace: bool
:returns: None, ``pyny.Space``
"""
state = Polygon.verify
Polygon.verify = False
mirror = np.ones(3)
if 'x' in axes:
mirror *= np.array([-1, 1, 1])
if 'y' in axes:
mirror *= np.array([1, -1, 1])
if 'z' in axes:
mirror *= np.array([1, 1, -1])
map_ = self.get_map()[1] * mirror
space = self.map2pyny(map_)
Polygon.verify = state
if inplace:
self.add_spaces(space)
return None
else:
return space
def photo(self, azimuth_zenit, plot=False):
"""
Computes a change of the reference system for the whole
``pyny.Space`` to align the `y` axis with a given direction.
Returns its elements (polygons, holes, points) extracted in a
list.
In its conception, this method was created as a tool for the
shadows computation to calculate "what is in front and what
is behind to the look of the Sun". For this reason, the
direction is given in spherical coordinates by two angles: the
azimth and the zenit.
* The azimuth is zero when pointing to the South, -pi/4 to
the East, pi/4 to the West and pi/2 to the North.
* The zenit is zero at the ground level and pi/4 "pointing
completely orthogonal to the sky".
In short, this methods answer "How would the ``pyny.Space`` look
in a photograph taken from an arbitrary direction in cylindrical
perpective?"
The photograph has a new reference system: x, y, depth. The sign
of the new depth coordinate has to be checked before assuming
what is closer and what is further inasmuch as it changes
depending on the direction of the photo.
:param azimuth_zenit: Direction of the photo in spherical
coordinates and in radians.
:type azimuth_zenit: tuple
:param plot: If True, is shows the photo visualization.
:type plot: bool
:returns: Exploded ``pyny.Space``
:rtype: list
.. note:: Before assume that this method do exactly what it is
supposed to do, it is highly recommended to visualy verify
throught the *plot=True* argument. It is easy to introduce
the angles in a different sign criteria, among other
frequent mistakes.
"""
self.lock()
a, z = azimuth_zenit
R = np.array([[np.cos(a), -np.sin(a)*np.cos(z), np.sin(z)*np.sin(a)],
[np.sin(a), np.cos(z)*np.cos(a), -np.cos(a)*np.sin(z)],
[0, np.sin(z), np.cos(z)]])
_, points = self.map
G = np.dot(R, points.T).T # Here it is in self.Space coordinates
# Coordinate change
G = np.array([G[:,0], G[:,2], G[:,1]]).T # Photograph coordinate
poly_hole_points = self.explode_map(G)
if plot:
polygons, holes, points = poly_hole_points
aux_surface = Surface(polygons, holes=holes, make_ccw=False)
ax = aux_surface.plot2d(alpha=0.6, ret=True)
if points.shape[0] > 0:
ax.scatter(points[:, 0], points[:, 1], c='#990000', s=25)
return poly_hole_points
def iplot(self, places=-1, c_poly='default', c_holes='default',
c_sop='r', s_sop=25, extra_height=0, ret=False, ax=None):
"""
Improved plot that allows to visualize the Places in the Space
selectively. It also allows to plot polygons and holes in
different colors and to change the size and the color of the
set of points.
The points can be plotted accordingly to a ndarray colormap.
:param places: Indexes of the Places to visualize.
:type places: int, list or ndarray
:param c_poly: Polygons color.
:type c_poly: matplotlib color, 'default' or 't' (transparent)
:param c_holes: Holes color.
:type c_holes: matplotlib color, 'default' or 't' (transparent)
:param c_sop: Set of points color.
:type c_sop: matplotlib color or colormap
:param s_sop: Set of points size.
:type s_sop: float or ndarray
:param ret: If True, returns the figure. It can be used to add
more elements to the plot or to modify it.
:type ret: bool
:param ax: If a matplotlib axes given, this method will
represent the plot on top of this axes. This is used to
represent multiple plots from multiple geometries,
overlapping them recursively.
:type ax: mplot3d.Axes3D, None
:returns: None, axes
:rtype: None, mplot3d.Axes3D
"""
if places == -1:
places = range(len(self.places))
elif type(places) == int:
places = [places]
places = np.array(places)
places[places<0] = len(self.places) + places[places<0]
places = np.unique(places)
aux_space = Space([self[i] for i in places])
for place in aux_space:
ax = place.iplot(c_poly, c_holes, c_sop, s_sop, extra_height,
ret=True, ax=ax)
aux_space.center_plot(ax)
if ret: return ax
def shadows(self, data=None, t=None, dt=None, latitude=None,
init='empty', resolution='mid'):
'''
Initializes a ShadowManager object for this ``pyny.Space``
instance.
The 'empty' initialization accepts ``data`` and ``t`` and ``dt``
but the ShadowsManager will not start the calculations. It will
wait the user to manually insert the rest of the parameters.
Call ``ShadowsManager.run()`` to start the shadowing
computations.
The 'auto' initialization pre-sets all the required parameters
to run the computations\*. The available resolutions are:
* 'low'
* 'mid'
* 'high'
The 'auto' mode will use all the arguments different than
``None`` and the ``set_of_points`` of this ``pyny.Space`` if
any.
:param data: Data timeseries to project on the 3D model
(radiation, for example).
:type data: ndarray (shape=N), None
:param t: Time vector in absolute minutes or datetime objects
:type t: ndarray or list, None
:param dt: Interval time to generate t vector.
:type dt: int, None
:param latitude: Local latitude.
:type latitude: float (radians)
:param init: Initialization mode
:type init: str
:param init: Resolution for the time vector generation (if
``None``), for setting the sensible points and for the
Voronoi diagram.
:type init: str
:returns: ``ShadowsManager`` object
'''
from pyny3d.shadows import ShadowsManager
if init == 'auto':
# Resolution
if resolution == 'low':
factor = 20
elif resolution == 'mid':
factor = 40
elif resolution == 'high':
factor = 70
if dt is None: dt = 6e4/factor
if latitude is None: latitude = 0.65
# Autofill ShadowsManager Object
sm = ShadowsManager(self, data=data, t=t, dt=dt,
latitude=latitude)
if self.get_sets_of_points().shape[0] == 0:
max_bound = np.diff(self.get_domain(), axis=0).max()
sm.space.mesh(mesh_size=max_bound/factor, edge=True)
## General parameters
sm.arg_vor_size = 3.5/factor
sm.run()
return sm
elif init == 'empty':
return ShadowsManager(self, data=data, t=t, dt=dt,
latitude=latitude)