This commit is contained in:
cjw
2026-02-12 23:22:11 +08:00
parent 7b09eb3d89
commit 89660bba4e
5988 changed files with 2517516 additions and 0 deletions
@@ -0,0 +1,968 @@
import numpy as np
from shapely import ops
from shapely.geometry import Polygon
from .. import bounds, geometry, graph, grouping
from ..constants import log
from ..constants import tol_path as tol
from ..iteration import reduce_cascade
from ..transformations import transform_points
from ..typed import ArrayLike, Iterable, NDArray, Number, Optional, Union, float64, int64
from .simplify import fit_circle_check
from .traversal import resample_path
try:
import networkx as nx
except BaseException as E:
# create a dummy module which will raise the ImportError
# or other exception only when someone tries to use networkx
from ..exceptions import ExceptionWrapper
nx = ExceptionWrapper(E)
try:
from rtree.index import Index
except BaseException as E:
# create a dummy module which will raise the ImportError
from ..exceptions import ExceptionWrapper
Index = ExceptionWrapper(E)
def enclosure_tree(polygons):
"""
Given a list of shapely polygons with only exteriors,
find which curves represent the exterior shell or root curve
and which represent holes which penetrate the exterior.
This is done with an R-tree for rough overlap detection,
and then exact polygon queries for a final result.
Parameters
-----------
polygons : (n,) shapely.geometry.Polygon
Polygons which only have exteriors and may overlap
Returns
-----------
roots : (m,) int
Index of polygons which are root
contains : networkx.DiGraph
Edges indicate a polygon is
contained by another polygon
"""
# nodes are indexes in polygons
contains = nx.DiGraph()
if len(polygons) == 0:
return np.array([], dtype=np.int64), contains
elif len(polygons) == 1:
# add an early exit for only a single polygon
contains.add_node(0)
return np.array([0], dtype=np.int64), contains
# get the bounds for every valid polygon
bounds = {
k: v
for k, v in {
i: getattr(polygon, "bounds", []) for i, polygon in enumerate(polygons)
}.items()
if len(v) == 4
}
# make sure we don't have orphaned polygon
contains.add_nodes_from(bounds.keys())
if len(bounds) > 0:
# if there are no valid bounds tree creation will fail
# and we won't be calling `tree.intersection` anywhere
# we could return here but having multiple return paths
# seems more dangerous than iterating through an empty graph
tree = Index(zip(bounds.keys(), bounds.values(), [None] * len(bounds)))
# loop through every polygon
for i, b in bounds.items():
# we first query for bounding box intersections from the R-tree
for j in tree.intersection(b):
# if we are checking a polygon against itself continue
if i == j:
continue
# do a more accurate polygon in polygon test
# for the enclosure tree information
if polygons[i].contains(polygons[j]):
contains.add_edge(i, j)
elif polygons[j].contains(polygons[i]):
contains.add_edge(j, i)
# a root or exterior curve has an even number of parents
# wrap in dict call to avoid networkx view
degree = dict(contains.in_degree())
# convert keys and values to numpy arrays
indexes = np.array(list(degree.keys()))
degrees = np.array(list(degree.values()))
# roots are curves with an even inward degree (parent count)
roots = indexes[(degrees % 2) == 0]
# if there are multiple nested polygons split the graph
# so the contains logic returns the individual polygons
if len(degrees) > 0 and degrees.max() > 1:
# collect new edges for graph
edges = []
# order the roots so they are sorted by degree
roots = roots[np.argsort([degree[r] for r in roots])]
# find edges of subgraph for each root and children
for root in roots:
children = indexes[degrees == degree[root] + 1]
edges.extend(contains.subgraph(np.append(children, root)).edges())
# stack edges into new directed graph
contains = nx.from_edgelist(edges, nx.DiGraph())
# if roots have no children add them anyway
contains.add_nodes_from(roots)
return roots, contains
def edges_to_polygons(edges: NDArray[int64], vertices: NDArray[float64]):
"""
Given an edge list of indices and associated vertices
representing lines, generate a list of polygons.
Parameters
-----------
edges : (n, 2)
Indexes of vertices which represent lines
vertices : (m, 2)
Vertices in 2D space.
Returns
----------
polygons : (p,) shapely.geometry.Polygon
Polygon objects with interiors
"""
assert isinstance(vertices, np.ndarray)
# create closed polygon objects
polygons = []
# loop through a sequence of ordered traversals
for dfs in graph.traversals(edges, mode="dfs"):
try:
# try to recover polygons before they are more complicated
repaired = repair_invalid(Polygon(vertices[dfs]))
# if it returned a multipolygon extend into a flat list
if hasattr(repaired, "geoms"):
polygons.extend(repaired.geoms)
else:
polygons.append(repaired)
except ValueError:
continue
# if there is only one polygon, just return it
if len(polygons) == 1:
return polygons
# find which polygons contain which other polygons
roots, tree = enclosure_tree(polygons)
# generate polygons with proper interiors
return [
Polygon(
shell=polygons[root].exterior,
holes=[polygons[i].exterior for i in tree[root].keys()],
)
for root in roots
]
def polygons_obb(polygons: Union[Iterable[Polygon], ArrayLike]):
"""
Find the OBBs for a list of shapely.geometry.Polygons
"""
rectangles = [None] * len(polygons)
transforms = [None] * len(polygons)
for i, p in enumerate(polygons):
transforms[i], rectangles[i] = polygon_obb(p)
return np.array(transforms), np.array(rectangles)
def polygon_obb(polygon: Union[Polygon, NDArray]):
"""
Find the oriented bounding box of a Shapely polygon.
The OBB is always aligned with an edge of the convex hull of the polygon.
Parameters
-------------
polygons : shapely.geometry.Polygon
Input geometry
Returns
-------------
transform : (3, 3) float
Transformation matrix
which will move input polygon from its original position
to the first quadrant where the AABB is the OBB
extents : (2,) float
Extents of transformed polygon
"""
if hasattr(polygon, "exterior"):
points = np.asanyarray(polygon.exterior.coords)
elif isinstance(polygon, np.ndarray):
points = polygon
else:
raise ValueError("polygon or points must be provided")
transform, extents = bounds.oriented_bounds_2D(points)
if tol.strict:
moved = transform_points(points=points, matrix=transform)
assert np.allclose(-extents / 2.0, moved.min(axis=0))
assert np.allclose(extents / 2.0, moved.max(axis=0))
return transform, extents
def transform_polygon(polygon, matrix):
"""
Transform a polygon by a a 2D homogeneous transform.
Parameters
-------------
polygon : shapely.geometry.Polygon
2D polygon to be transformed.
matrix : (3, 3) float
2D homogeneous transformation.
Returns
--------------
result : shapely.geometry.Polygon
Polygon transformed by matrix.
"""
matrix = np.asanyarray(matrix, dtype=np.float64)
if hasattr(polygon, "geoms"):
result = [transform_polygon(p, t) for p, t in zip(polygon, matrix)]
return result
# transform the outer shell
shell = transform_points(np.array(polygon.exterior.coords), matrix)[:, :2]
# transform the interiors
holes = [
transform_points(np.array(i.coords), matrix)[:, :2] for i in polygon.interiors
]
# create a new polygon with the result
result = Polygon(shell=shell, holes=holes)
return result
def polygon_bounds(polygon, matrix=None):
"""
Get the transformed axis aligned bounding box of a
shapely Polygon object.
Parameters
------------
polygon : shapely.geometry.Polygon
Polygon pre-transform
matrix : (3, 3) float or None.
Homogeneous transform moving polygon in space
Returns
------------
bounds : (2, 2) float
Axis aligned bounding box of transformed polygon.
"""
if matrix is not None:
assert matrix.shape == (3, 3)
points = transform_points(points=np.array(polygon.exterior.coords), matrix=matrix)
else:
points = np.array(polygon.exterior.coords)
bounds = np.array([points.min(axis=0), points.max(axis=0)])
assert bounds.shape == (2, 2)
return bounds
def plot(polygon=None, show=True, axes=None, **kwargs):
"""
Plot a shapely polygon using matplotlib.
Parameters
------------
polygon : shapely.geometry.Polygon
Polygon to be plotted
show : bool
If True will display immediately
**kwargs
Passed to plt.plot
"""
import matplotlib.pyplot as plt
def plot_single(single):
axes.plot(*single.exterior.xy, **kwargs)
for interior in single.interiors:
axes.plot(*interior.xy, **kwargs)
# make aspect ratio non-stupid
if axes is None:
axes = plt.axes()
axes.set_aspect("equal", "datalim")
if polygon.__class__.__name__ == "MultiPolygon":
[plot_single(i) for i in polygon.geoms]
elif hasattr(polygon, "__iter__"):
[plot_single(i) for i in polygon]
elif polygon is not None:
plot_single(polygon)
if show:
plt.show()
return axes
def resample_boundaries(polygon: Polygon, resolution: float, clip=None):
"""
Return a version of a polygon with boundaries re-sampled
to a specified resolution.
Parameters
-------------
polygon : shapely.geometry.Polygon
Source geometry
resolution : float
Desired distance between points on boundary
clip : (2,) int
Upper and lower bounds to clip
number of samples to avoid exploding count
Returns
------------
kwargs : dict
Keyword args for a Polygon constructor `Polygon(**kwargs)`
"""
def resample_boundary(boundary):
# add a polygon.exterior or polygon.interior to
# the deque after resampling based on our resolution
count = boundary.length / resolution
count = int(np.clip(count, *clip))
return resample_path(boundary.coords, count=count)
if clip is None:
clip = [8, 200]
# create a sequence of [(n,2)] points
kwargs = {"shell": resample_boundary(polygon.exterior), "holes": []}
for interior in polygon.interiors:
kwargs["holes"].append(resample_boundary(interior))
return kwargs
def stack_boundaries(boundaries):
"""
Stack the boundaries of a polygon into a single
(n, 2) list of vertices.
Parameters
------------
boundaries : dict
With keys 'shell', 'holes'
Returns
------------
stacked : (n, 2) float
Stacked vertices
"""
if len(boundaries["holes"]) == 0:
return boundaries["shell"]
return np.vstack((boundaries["shell"], np.vstack(boundaries["holes"])))
def medial_axis(polygon: Polygon, resolution: Optional[Number] = None, clip=None):
"""
Given a shapely polygon, find the approximate medial axis
using a voronoi diagram of evenly spaced points on the
boundary of the polygon.
Parameters
----------
polygon : shapely.geometry.Polygon
The source geometry
resolution : float
Distance between each sample on the polygon boundary
clip : None, or (2,) int
Clip sample count to min of clip[0] and max of clip[1]
Returns
----------
edges : (n, 2) int
Vertex indices representing line segments
on the polygon's medial axis
vertices : (m, 2) float
Vertex positions in space
"""
# a circle will have a single point medial axis
if len(polygon.interiors) == 0:
# what is the approximate scale of the polygon
scale = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).max()
# a (center, radius, error) tuple
fit = fit_circle_check(polygon.exterior.coords, scale=scale)
# is this polygon in fact a circle
if fit is not None:
# return an edge that has the center as the midpoint
epsilon = np.clip(fit["radius"] / 500, 1e-5, np.inf)
vertices = np.array(
[fit["center"] + [0, epsilon], fit["center"] - [0, epsilon]],
dtype=np.float64,
)
# return a single edge to avoid consumers needing to special case
edges = np.array([[0, 1]], dtype=np.int64)
return edges, vertices
from scipy.spatial import Voronoi
from shapely import vectorized
if resolution is None:
resolution = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).max() / 100
# get evenly spaced points on the polygons boundaries
samples = resample_boundaries(polygon=polygon, resolution=resolution, clip=clip)
# stack the boundary into a (m,2) float array
samples = stack_boundaries(samples)
# create the voronoi diagram on 2D points
voronoi = Voronoi(samples)
# which voronoi vertices are contained inside the polygon
contains = vectorized.contains(polygon, *voronoi.vertices.T)
# ridge vertices of -1 are outside, make sure they are False
contains = np.append(contains, False)
# make sure ridge vertices is numpy array
ridge = np.asanyarray(voronoi.ridge_vertices, dtype=np.int64)
# only take ridges where every vertex is contained
edges = ridge[contains[ridge].all(axis=1)]
# now we need to remove uncontained vertices
contained = np.unique(edges)
mask = np.zeros(len(voronoi.vertices), dtype=np.int64)
mask[contained] = np.arange(len(contained))
# mask voronoi vertices
vertices = voronoi.vertices[contained]
# re-index edges
edges_final = mask[edges]
if tol.strict:
# make sure we didn't screw up indexes
assert np.ptp(vertices[edges_final] - voronoi.vertices[edges]) < 1e-5
return edges_final, vertices
def identifier(polygon: Polygon) -> NDArray[float64]:
"""
Return a vector containing values representative of
a particular polygon.
Parameters
---------
polygon : shapely.geometry.Polygon
Input geometry
Returns
---------
identifier : (8,) float
Values which should be unique for this polygon.
"""
result = [
len(polygon.interiors),
polygon.convex_hull.area,
polygon.convex_hull.length,
polygon.area,
polygon.length,
polygon.exterior.length,
]
# include the principal second moments of inertia of the polygon
# this is invariant to rotation and translation
_, principal, _, _ = second_moments(polygon, return_centered=True)
result.extend(principal)
return np.array(result, dtype=np.float64)
def random_polygon(segments=8, radius=1.0):
"""
Generate a random polygon with a maximum number of sides and approximate radius.
Parameters
---------
segments : int
The maximum number of sides the random polygon will have
radius : float
The approximate radius of the polygon desired
Returns
---------
polygon : shapely.geometry.Polygon
Geometry object with random exterior and no interiors.
"""
angles = np.sort(np.cumsum(np.random.random(segments) * np.pi * 2) % (np.pi * 2))
radii = np.random.random(segments) * radius
points = np.column_stack((np.cos(angles), np.sin(angles))) * radii.reshape((-1, 1))
points = np.vstack((points, points[0]))
polygon = Polygon(points).buffer(0.0)
if hasattr(polygon, "geoms"):
return polygon.geoms[0]
return polygon
def polygon_scale(polygon):
"""
For a Polygon object return the diagonal length of the AABB.
Parameters
------------
polygon : shapely.geometry.Polygon
Source geometry
Returns
------------
scale : float
Length of AABB diagonal
"""
extents = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0)
scale = (extents**2).sum() ** 0.5
return scale
def paths_to_polygons(paths, scale=None):
"""
Given a sequence of connected points turn them into
valid shapely Polygon objects.
Parameters
-----------
paths : (n,) sequence
Of (m, 2) float closed paths
scale : float
Approximate scale of drawing for precision
Returns
-----------
polys : (p,) list
Filled with Polygon or None
"""
polygons = [None] * len(paths)
for i, path in enumerate(paths):
if len(path) < 4:
# since the first and last vertices are identical in
# a closed loop a 4 vertex path is the minimum for
# non-zero area
continue
try:
polygon = Polygon(path)
if polygon.is_valid:
polygons[i] = polygon
else:
polygons[i] = repair_invalid(polygon, scale)
except ValueError:
# raised if a polygon is unrecoverable
continue
except BaseException:
log.error("unrecoverable polygon", exc_info=True)
polygons = np.array(polygons)
return polygons
def sample(polygon, count, factor=1.5, max_iter=10):
"""
Use rejection sampling to generate random points inside a
polygon. Note that this function may return fewer or no
points, in particular if the polygon as very little area
compared to the area of the axis-aligned bounding box.
Parameters
-----------
polygon : shapely.geometry.Polygon
Polygon that will contain points
count : int
Number of points to return
factor : float
How many points to test per loop
max_iter : int
Maximum number of intersection checks is:
> count * factor * max_iter
Returns
-----------
hit : (n, 2) float
Random points inside polygon
where n <= count
"""
# do batch point-in-polygon queries
from shapely import vectorized
# TODO : this should probably have some option to
# sample from the *oriented* bounding box which would
# make certain cases much, much more efficient.
# get size of bounding box
bounds = np.reshape(polygon.bounds, (2, 2))
extents = np.ptp(bounds, axis=0)
# how many points to check per loop iteration
per_loop = int(count * factor)
# start with some rejection sampling
points = bounds[0] + extents * np.random.random((per_loop, 2))
# do the point in polygon test and append resulting hits
mask = vectorized.contains(polygon, *points.T)
hit = [points[mask]]
hit_count = len(hit[0])
# if our first non-looping check got enough samples exit
if hit_count >= count:
return hit[0][:count]
# if we have to do iterations loop here slowly
for _ in range(max_iter):
# generate points inside polygons AABB
points = (np.random.random((per_loop, 2)) * extents) + bounds[0]
# do the point in polygon test and append resulting hits
mask = vectorized.contains(polygon, *points.T)
hit.append(points[mask])
# keep track of how many points we've collected
hit_count += len(hit[-1])
# if we have enough points exit the loop
if hit_count > count:
break
# stack the hits into an (n,2) array and truncate
hit = np.vstack(hit)[:count]
return hit
def repair_invalid(polygon, scale=None, rtol=0.5):
"""
Given a shapely.geometry.Polygon, attempt to return a
valid version of the polygon through buffering tricks.
Parameters
-----------
polygon : shapely.geometry.Polygon
Source geometry
rtol : float
How close does a perimeter have to be
scale : float or None
For numerical precision reference
Returns
----------
repaired : shapely.geometry.Polygon
Repaired polygon
Raises
----------
ValueError
If polygon can't be repaired
"""
if hasattr(polygon, "is_valid") and polygon.is_valid:
return polygon
# basic repair involves buffering the polygon outwards
# this will fix a subset of problems.
basic = polygon.buffer(tol.zero)
# if it returned multiple polygons check the largest
if hasattr(basic, "geoms"):
basic = basic.geoms[np.argmax([i.area for i in basic.geoms])]
# check perimeter of result against original perimeter
if basic.is_valid and np.isclose(basic.length, polygon.length, rtol=rtol):
return basic
if scale is None:
distance = 0.002 * np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).mean()
else:
distance = 0.002 * scale
# if there are no interiors, we can work with just the exterior
# ring, which is often more reliable
if len(polygon.interiors) == 0:
# try buffering the exterior of the polygon
# the interior will be offset by -tol.buffer
rings = polygon.exterior.buffer(distance).interiors
if len(rings) == 1:
# reconstruct a single polygon from the interior ring
recon = Polygon(shell=rings[0]).buffer(distance)
# check perimeter of result against original perimeter
if recon.is_valid and np.isclose(recon.length, polygon.length, rtol=rtol):
return recon
# try de-deuplicating the outside ring
points = np.array(polygon.exterior.coords)
# remove any segments shorter than tol.merge
# this is a little risky as if it was discretized more
# finely than 1-e8 it may remove detail
unique = np.append(True, (np.diff(points, axis=0) ** 2).sum(axis=1) ** 0.5 > 1e-8)
# make a new polygon with result
dedupe = Polygon(shell=points[unique])
# check result
if dedupe.is_valid and np.isclose(dedupe.length, polygon.length, rtol=rtol):
return dedupe
# buffer and unbuffer the whole polygon
buffered = polygon.buffer(distance).buffer(-distance)
# if it returned multiple polygons check the largest
if hasattr(buffered, "geoms"):
areas = np.array([b.area for b in buffered.geoms])
return buffered.geoms[areas.argmax()]
# check perimeter of result against original perimeter
if buffered.is_valid and np.isclose(buffered.length, polygon.length, rtol=rtol):
log.debug("Recovered invalid polygon through double buffering")
return buffered
raise ValueError("unable to recover polygon!")
def projected(
mesh,
normal,
origin=None,
ignore_sign=True,
rpad=1e-5,
apad=None,
tol_dot=1e-10,
precise: bool = False,
):
"""
Project a mesh onto a plane and then extract the polygon
that outlines the mesh projection on that plane.
Note that this will ignore back-faces, which is only
relevant if the source mesh isn't watertight.
Also padding: this generates a result by unioning the
polygons of multiple connected regions, which requires
the polygons be padded by a distance so that a polygon
union produces a single coherent result. This distance
is calculated as: `apad + (rpad * scale)`
Parameters
----------
mesh : trimesh.Trimesh
Source geometry
check : bool
If True make sure is flat
normal : (3,) float
Normal to extract flat pattern along
origin : None or (3,) float
Origin of plane to project mesh onto
ignore_sign : bool
Allow a projection from the normal vector in
either direction: this provides a substantial speedup
on watertight meshes where the direction is irrelevant
but if you have a triangle soup and want to discard
backfaces you should set this to False.
rpad : float
Proportion to pad polygons by before unioning
and then de-padding result by to avoid zero-width gaps.
apad : float
Absolute padding to pad polygons by before unioning
and then de-padding result by to avoid zero-width gaps.
tol_dot : float
Tolerance for discarding on-edge triangles.
max_regions : int
Raise an exception if the mesh has more than this
number of disconnected regions to fail quickly before
unioning.
Returns
----------
projected : shapely.geometry.Polygon or None
Outline of source mesh
Raises
---------
ValueError
If max_regions is exceeded
"""
# make sure normal is a unitized copy
normal = np.array(normal, dtype=np.float64)
normal /= np.linalg.norm(normal)
# the projection of each face normal onto facet normal
dot_face = np.dot(normal, mesh.face_normals.T)
if ignore_sign:
# for watertight mesh speed up projection by handling side with less faces
# check if face lies on front or back of normal
front = dot_face > tol_dot
back = dot_face < -tol_dot
# divide the mesh into front facing section and back facing parts
# and discard the faces perpendicular to the axis.
# since we are doing a unary_union later we can use the front *or*
# the back so we use which ever one has fewer triangles
# we want the largest nonzero group
count = np.array([front.sum(), back.sum()])
if count.min() == 0:
# if one of the sides has zero faces we need the other
pick = count.argmax()
else:
# otherwise use the normal direction with the fewest faces
pick = count.argmin()
# use the picked side
side = [front, back][pick]
else:
# if explicitly asked to care about the sign
# only handle the front side of normal
side = dot_face > tol_dot
# subset the adjacency pairs to ones which have both faces included
# on the side we are currently looking at
adjacency_check = side[mesh.face_adjacency].all(axis=1)
adjacency = mesh.face_adjacency[adjacency_check]
# transform from the mesh frame in 3D to the XY plane
to_2D = geometry.plane_transform(origin=origin, normal=normal)
# transform mesh vertices to 2D and clip the zero Z
vertices_2D = transform_points(mesh.vertices, to_2D)[:, :2]
if precise:
eps = 1e-10
faces = mesh.faces[side]
# just union all the polygons
return (
ops.unary_union(
[Polygon(f) for f in vertices_2D[np.column_stack((faces, faces[:, :1]))]]
)
.buffer(eps)
.buffer(-eps)
)
# a sequence of face indexes that are connected
face_groups = graph.connected_components(adjacency, nodes=np.nonzero(side)[0])
# reshape edges into shape length of faces for indexing
edges = mesh.edges_sorted.reshape((-1, 6))
polygons = []
for faces in face_groups:
# index edges by face then shape back to individual edges
edge = edges[faces].reshape((-1, 2))
# edges that occur only once are on the boundary
group = grouping.group_rows(edge, require_count=1)
# turn each region into polygons
polygons.extend(edges_to_polygons(edges=edge[group], vertices=vertices_2D))
padding = 0.0
if apad is not None:
# set padding by absolute value
padding += float(apad)
if rpad is not None:
# get the 2D scale as the longest side of the AABB
scale = np.ptp(vertices_2D, axis=0).max()
# apply the scale-relative padding
padding += float(rpad) * scale
# if there is only one region we don't need to run a union
elif len(polygons) == 1:
return polygons[0]
elif len(polygons) == 0:
return None
# in my tests this was substantially faster than `shapely.ops.unary_union`
reduced = reduce_cascade(lambda a, b: a.union(b), polygons)
# can be None
if reduced is not None:
return reduced.buffer(padding).buffer(-padding)
def second_moments(polygon: Polygon, return_centered=False):
"""
Calculate the second moments of area of a polygon
from the boundary.
Parameters
------------
polygon : shapely.geometry.Polygon
Closed polygon.
return_centered : bool
Get second moments for a frame with origin at the centroid
and perform a principal axis transformation.
Returns
----------
moments : (3,) float
The values of `[Ixx, Iyy, Ixy]`
principal_moments : (2,) float
Principal second moments of inertia: `[Imax, Imin]`
Only returned if `centered`.
alpha : float
Angle by which the polygon needs to be rotated, so the
principal axis align with the X and Y axis.
Only returned if `centered`.
transform : (3, 3) float
Transformation matrix which rotates the polygon by alpha.
Only returned if `centered`.
"""
transform = np.eye(3)
if return_centered:
# calculate centroid and move polygon
transform[:2, 2] = -np.array(polygon.centroid.coords)
polygon = transform_polygon(polygon, transform)
# start with the exterior
coords = np.array(polygon.exterior.coords)
# shorthand the coordinates
x1, y1 = np.vstack((coords[-1], coords[:-1])).T
x2, y2 = coords.T
# do vectorized operations
v = x1 * y2 - x2 * y1
Ixx = np.sum(v * (y1 * y1 + y1 * y2 + y2 * y2)) / 12.0
Iyy = np.sum(v * (x1 * x1 + x1 * x2 + x2 * x2)) / 12.0
Ixy = np.sum(v * (x1 * y2 + 2 * x1 * y1 + 2 * x2 * y2 + x2 * y1)) / 24.0
for interior in polygon.interiors:
coords = np.array(interior.coords)
# shorthand the coordinates
x1, y1 = np.vstack((coords[-1], coords[:-1])).T
x2, y2 = coords.T
# do vectorized operations
v = x1 * y2 - x2 * y1
Ixx -= np.sum(v * (y1 * y1 + y1 * y2 + y2 * y2)) / 12.0
Iyy -= np.sum(v * (x1 * x1 + x1 * x2 + x2 * x2)) / 12.0
Ixy -= np.sum(v * (x1 * y2 + 2 * x1 * y1 + 2 * x2 * y2 + x2 * y1)) / 24.0
moments = [Ixx, Iyy, Ixy]
if not return_centered:
return moments
# get the principal moments
root = np.sqrt(((Iyy - Ixx) / 2.0) ** 2 + Ixy**2)
Imax = (Ixx + Iyy) / 2.0 + root
Imin = (Ixx + Iyy) / 2.0 - root
principal_moments = [Imax, Imin]
# do the principal axis transform
if np.isclose(Ixy, 0.0, atol=1e-12):
alpha = 0
elif np.isclose(Ixx, Iyy):
# prevent division by 0
alpha = 0.25 * np.pi
else:
alpha = 0.5 * np.arctan(2.0 * Ixy / (Ixx - Iyy))
# construct transformation matrix
cos_alpha = np.cos(alpha)
sin_alpha = np.sin(alpha)
transform[0, 0] = cos_alpha
transform[1, 1] = cos_alpha
transform[0, 1] = -sin_alpha
transform[1, 0] = sin_alpha
return moments, principal_moments, alpha, transform