Coverage for trimesh/path/polygons.py: 85%
336 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
1import numpy as np
2from shapely import ops
3from shapely.geometry import Polygon
5from .. import bounds, geometry, graph, grouping
6from ..constants import log
7from ..constants import tol_path as tol
8from ..iteration import reduce_cascade
9from ..transformations import transform_points
10from ..typed import ArrayLike, Iterable, NDArray, Number, Seed, float64, int64
11from ..util import random_generator
12from .simplify import fit_circle_check
13from .traversal import resample_path
15try:
16 import networkx as nx
17except BaseException as E:
18 # create a dummy module which will raise the ImportError
19 # or other exception only when someone tries to use networkx
20 from ..exceptions import ExceptionWrapper
22 nx = ExceptionWrapper(E)
23try:
24 from rtree.index import Index
25except BaseException as E:
26 # create a dummy module which will raise the ImportError
27 from ..exceptions import ExceptionWrapper
29 Index = ExceptionWrapper(E)
32def enclosure_tree(polygons):
33 """
34 Given a list of shapely polygons with only exteriors,
35 find which curves represent the exterior shell or root curve
36 and which represent holes which penetrate the exterior.
38 This is done with an R-tree for rough overlap detection,
39 and then exact polygon queries for a final result.
41 Parameters
42 -----------
43 polygons : (n,) shapely.geometry.Polygon
44 Polygons which only have exteriors and may overlap
46 Returns
47 -----------
48 roots : (m,) int
49 Index of polygons which are root
50 contains : networkx.DiGraph
51 Edges indicate a polygon is
52 contained by another polygon
53 """
55 # nodes are indexes in polygons
56 contains = nx.DiGraph()
58 if len(polygons) == 0:
59 return np.array([], dtype=np.int64), contains
60 elif len(polygons) == 1:
61 # `paths_to_polygons` may produce `None` for unrecoverable
62 # geometry: never emit it as a root, matching the multi-polygon
63 # code path below where `None` is excluded by the bounds check
64 if polygons[0] is None:
65 return np.array([], dtype=np.int64), contains
66 # add an early exit for only a single polygon
67 contains.add_node(0)
68 return np.array([0], dtype=np.int64), contains
70 # get the bounds for every valid polygon
71 bounds = {
72 k: v
73 for k, v in {
74 i: getattr(polygon, "bounds", []) for i, polygon in enumerate(polygons)
75 }.items()
76 if len(v) == 4
77 }
79 # make sure we don't have orphaned polygon
80 contains.add_nodes_from(bounds.keys())
82 if len(bounds) > 0:
83 # if there are no valid bounds tree creation will fail
84 # and we won't be calling `tree.intersection` anywhere
85 # we could return here but having multiple return paths
86 # seems more dangerous than iterating through an empty graph
87 tree = Index(zip(bounds.keys(), bounds.values(), [None] * len(bounds)))
89 # loop through every polygon
90 for i, b in bounds.items():
91 # we first query for bounding box intersections from the R-tree
92 for j in tree.intersection(b):
93 # if we are checking a polygon against itself continue
94 if i == j:
95 continue
96 # do a more accurate polygon in polygon test
97 # for the enclosure tree information
98 if polygons[i].contains(polygons[j]):
99 contains.add_edge(i, j)
100 elif polygons[j].contains(polygons[i]):
101 contains.add_edge(j, i)
103 # a root or exterior curve has an even number of parents
104 # wrap in dict call to avoid networkx view
105 degree = dict(contains.in_degree())
106 # convert keys and values to numpy arrays
107 indexes = np.array(list(degree.keys()))
108 degrees = np.array(list(degree.values()))
109 # roots are curves with an even inward degree (parent count)
110 roots = indexes[(degrees % 2) == 0]
111 # if there are multiple nested polygons split the graph
112 # so the contains logic returns the individual polygons
113 if len(degrees) > 0 and degrees.max() > 1:
114 # collect new edges for graph
115 edges = []
116 # order the roots so they are sorted by degree
117 roots = roots[np.argsort([degree[r] for r in roots])]
118 # find edges of subgraph for each root and children
119 for root in roots:
120 children = indexes[degrees == degree[root] + 1]
121 edges.extend(contains.subgraph(np.append(children, root)).edges())
122 # stack edges into new directed graph
123 contains = nx.from_edgelist(edges, nx.DiGraph())
124 # if roots have no children add them anyway
125 contains.add_nodes_from(roots)
127 return roots, contains
130def edges_to_polygons(edges: NDArray[int64], vertices: NDArray[float64]):
131 """
132 Given an edge list of indices and associated vertices
133 representing lines, generate a list of polygons.
135 Parameters
136 -----------
137 edges : (n, 2)
138 Indexes of vertices which represent lines
139 vertices : (m, 2)
140 Vertices in 2D space.
142 Returns
143 ----------
144 polygons : (p,) shapely.geometry.Polygon
145 Polygon objects with interiors
146 """
148 assert isinstance(vertices, np.ndarray)
150 # create closed polygon objects
151 polygons = []
152 # loop through a sequence of ordered traversals
153 for dfs in graph.traversals(edges, mode="dfs"):
154 try:
155 # try to recover polygons before they are more complicated
156 repaired = repair_invalid(Polygon(vertices[dfs]))
157 # if it returned a multipolygon extend into a flat list
158 if hasattr(repaired, "geoms"):
159 polygons.extend(repaired.geoms)
160 else:
161 polygons.append(repaired)
162 except ValueError:
163 continue
165 # if there is only one polygon, just return it
166 if len(polygons) == 1:
167 return polygons
169 # find which polygons contain which other polygons
170 roots, tree = enclosure_tree(polygons)
172 # generate polygons with proper interiors
173 return [
174 Polygon(
175 shell=polygons[root].exterior,
176 holes=[polygons[i].exterior for i in tree[root].keys()],
177 )
178 for root in roots
179 ]
182def polygons_obb(polygons: Iterable[Polygon] | ArrayLike):
183 """
184 Find the OBBs for a list of shapely.geometry.Polygons
185 """
186 rectangles = [None] * len(polygons)
187 transforms = [None] * len(polygons)
188 for i, p in enumerate(polygons):
189 transforms[i], rectangles[i] = polygon_obb(p)
190 return np.array(transforms), np.array(rectangles)
193def polygon_obb(polygon: Polygon | NDArray):
194 """
195 Find the oriented bounding box of a Shapely polygon.
197 The OBB is always aligned with an edge of the convex hull of the polygon.
199 Parameters
200 -------------
201 polygons : shapely.geometry.Polygon
202 Input geometry
204 Returns
205 -------------
206 transform : (3, 3) float
207 Transformation matrix
208 which will move input polygon from its original position
209 to the first quadrant where the AABB is the OBB
210 extents : (2,) float
211 Extents of transformed polygon
212 """
213 if hasattr(polygon, "exterior"):
214 points = np.asanyarray(polygon.exterior.coords)
215 elif isinstance(polygon, np.ndarray):
216 points = polygon
217 else:
218 raise ValueError("polygon or points must be provided")
220 transform, extents = bounds.oriented_bounds_2D(points)
222 if tol.strict:
223 moved = transform_points(points=points, matrix=transform)
224 assert np.allclose(-extents / 2.0, moved.min(axis=0))
225 assert np.allclose(extents / 2.0, moved.max(axis=0))
227 return transform, extents
230def transform_polygon(polygon, matrix):
231 """
232 Transform a polygon by a a 2D homogeneous transform.
234 Parameters
235 -------------
236 polygon : shapely.geometry.Polygon
237 2D polygon to be transformed.
238 matrix : (3, 3) float
239 2D homogeneous transformation.
241 Returns
242 --------------
243 result : shapely.geometry.Polygon
244 Polygon transformed by matrix.
245 """
246 matrix = np.asanyarray(matrix, dtype=np.float64)
248 if hasattr(polygon, "geoms"):
249 result = [transform_polygon(p, t) for p, t in zip(polygon, matrix)]
250 return result
251 # transform the outer shell
252 shell = transform_points(np.array(polygon.exterior.coords), matrix)[:, :2]
253 # transform the interiors
254 holes = [
255 transform_points(np.array(i.coords), matrix)[:, :2] for i in polygon.interiors
256 ]
257 # create a new polygon with the result
258 result = Polygon(shell=shell, holes=holes)
259 return result
262def polygon_bounds(polygon, matrix=None):
263 """
264 Get the transformed axis aligned bounding box of a
265 shapely Polygon object.
267 Parameters
268 ------------
269 polygon : shapely.geometry.Polygon
270 Polygon pre-transform
271 matrix : (3, 3) float or None.
272 Homogeneous transform moving polygon in space
274 Returns
275 ------------
276 bounds : (2, 2) float
277 Axis aligned bounding box of transformed polygon.
278 """
279 if matrix is not None:
280 assert matrix.shape == (3, 3)
281 points = transform_points(points=np.array(polygon.exterior.coords), matrix=matrix)
282 else:
283 points = np.array(polygon.exterior.coords)
285 bounds = np.array([points.min(axis=0), points.max(axis=0)])
286 assert bounds.shape == (2, 2)
287 return bounds
290def plot(polygon=None, show=True, axes=None, **kwargs):
291 """
292 Plot a shapely polygon using matplotlib.
294 Parameters
295 ------------
296 polygon : shapely.geometry.Polygon
297 Polygon to be plotted
298 show : bool
299 If True will display immediately
300 **kwargs
301 Passed to plt.plot
302 """
303 import matplotlib.pyplot as plt # noqa
305 def plot_single(single):
306 axes.plot(*single.exterior.xy, **kwargs)
307 for interior in single.interiors:
308 axes.plot(*interior.xy, **kwargs)
310 # make aspect ratio non-stupid
311 if axes is None:
312 axes = plt.axes()
313 axes.set_aspect("equal", "datalim")
315 if polygon.__class__.__name__ == "MultiPolygon":
316 [plot_single(i) for i in polygon.geoms]
317 elif hasattr(polygon, "__iter__"):
318 [plot_single(i) for i in polygon]
319 elif polygon is not None:
320 plot_single(polygon)
322 if show:
323 plt.show()
325 return axes
328def resample_boundaries(polygon: Polygon, resolution: float, clip=None):
329 """
330 Return a version of a polygon with boundaries re-sampled
331 to a specified resolution.
333 Parameters
334 -------------
335 polygon : shapely.geometry.Polygon
336 Source geometry
337 resolution : float
338 Desired distance between points on boundary
339 clip : (2,) int
340 Upper and lower bounds to clip
341 number of samples to avoid exploding count
343 Returns
344 ------------
345 kwargs : dict
346 Keyword args for a Polygon constructor `Polygon(**kwargs)`
347 """
349 def resample_boundary(boundary):
350 # add a polygon.exterior or polygon.interior to
351 # the deque after resampling based on our resolution
352 count = boundary.length / resolution
353 count = int(np.clip(count, *clip))
354 return resample_path(boundary.coords, count=count)
356 if clip is None:
357 clip = [8, 200]
358 # create a sequence of [(n,2)] points
359 kwargs = {"shell": resample_boundary(polygon.exterior), "holes": []}
360 for interior in polygon.interiors:
361 kwargs["holes"].append(resample_boundary(interior))
363 return kwargs
366def stack_boundaries(boundaries):
367 """
368 Stack the boundaries of a polygon into a single
369 (n, 2) list of vertices.
371 Parameters
372 ------------
373 boundaries : dict
374 With keys 'shell', 'holes'
376 Returns
377 ------------
378 stacked : (n, 2) float
379 Stacked vertices
380 """
381 if len(boundaries["holes"]) == 0:
382 return boundaries["shell"]
383 return np.vstack((boundaries["shell"], np.vstack(boundaries["holes"])))
386def medial_axis(polygon: Polygon, resolution: Number | None = None, clip=None):
387 """
388 Given a shapely polygon, find the approximate medial axis
389 using a voronoi diagram of evenly spaced points on the
390 boundary of the polygon.
392 Parameters
393 ----------
394 polygon : shapely.geometry.Polygon
395 The source geometry
396 resolution : float
397 Distance between each sample on the polygon boundary
398 clip : None, or (2,) int
399 Clip sample count to min of clip[0] and max of clip[1]
401 Returns
402 ----------
403 edges : (n, 2) int
404 Vertex indices representing line segments
405 on the polygon's medial axis
406 vertices : (m, 2) float
407 Vertex positions in space
408 """
409 # a circle will have a single point medial axis
410 if len(polygon.interiors) == 0:
411 # what is the approximate scale of the polygon
412 scale = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).max()
413 # a (center, radius, error) tuple
414 fit = fit_circle_check(polygon.exterior.coords, scale=scale)
415 # is this polygon in fact a circle
416 if fit is not None:
417 # return an edge that has the center as the midpoint
418 epsilon = np.clip(fit["radius"] / 500, 1e-5, np.inf)
419 vertices = np.array(
420 [fit["center"] + [0, epsilon], fit["center"] - [0, epsilon]],
421 dtype=np.float64,
422 )
423 # return a single edge to avoid consumers needing to special case
424 edges = np.array([[0, 1]], dtype=np.int64)
425 return edges, vertices
427 from scipy.spatial import Voronoi
428 from shapely import vectorized
430 if resolution is None:
431 resolution = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).max() / 100
433 # get evenly spaced points on the polygons boundaries
434 samples = resample_boundaries(polygon=polygon, resolution=resolution, clip=clip)
435 # stack the boundary into a (m,2) float array
436 samples = stack_boundaries(samples)
437 # create the voronoi diagram on 2D points
438 voronoi = Voronoi(samples)
439 # which voronoi vertices are contained inside the polygon
440 contains = vectorized.contains(polygon, *voronoi.vertices.T)
441 # ridge vertices of -1 are outside, make sure they are False
442 contains = np.append(contains, False)
443 # make sure ridge vertices is numpy array
444 ridge = np.asanyarray(voronoi.ridge_vertices, dtype=np.int64)
445 # only take ridges where every vertex is contained
446 edges = ridge[contains[ridge].all(axis=1)]
448 # now we need to remove uncontained vertices
449 contained = np.unique(edges)
450 mask = np.zeros(len(voronoi.vertices), dtype=np.int64)
451 mask[contained] = np.arange(len(contained))
453 # mask voronoi vertices
454 vertices = voronoi.vertices[contained]
455 # re-index edges
456 edges_final = mask[edges]
458 if tol.strict:
459 # make sure we didn't screw up indexes
460 assert np.ptp(vertices[edges_final] - voronoi.vertices[edges]) < 1e-5
462 return edges_final, vertices
465def identifier(polygon: Polygon) -> NDArray[float64]:
466 """
467 Return a vector containing values representative of
468 a particular polygon.
470 Parameters
471 ---------
472 polygon : shapely.geometry.Polygon
473 Input geometry
475 Returns
476 ---------
477 identifier : (8,) float
478 Values which should be unique for this polygon.
479 """
480 result = [
481 len(polygon.interiors),
482 polygon.convex_hull.area,
483 polygon.convex_hull.length,
484 polygon.area,
485 polygon.length,
486 polygon.exterior.length,
487 ]
488 # include the principal second moments of inertia of the polygon
489 # this is invariant to rotation and translation
490 _, principal, _, _ = second_moments(polygon, return_centered=True)
491 result.extend(principal)
493 return np.array(result, dtype=np.float64)
496def random_polygon(segments=8, radius=1.0, seed: Seed = None):
497 """
498 Generate a random polygon with a maximum number of sides and approximate radius.
500 Parameters
501 ---------
502 segments : int
503 The maximum number of sides the random polygon will have
504 radius : float
505 The approximate radius of the polygon desired
506 seed : None or int
507 Seed for deterministic results, otherwise OS entropy.
509 Returns
510 ---------
511 polygon : shapely.geometry.Polygon
512 Geometry object with random exterior and no interiors.
513 """
514 random = random_generator(seed)
515 angles = np.sort(np.cumsum(random.random(segments) * np.pi * 2) % (np.pi * 2))
516 radii = random.random(segments) * radius
518 points = np.column_stack((np.cos(angles), np.sin(angles))) * radii.reshape((-1, 1))
519 points = np.vstack((points, points[0]))
520 polygon = Polygon(points).buffer(0.0)
521 if hasattr(polygon, "geoms"):
522 return polygon.geoms[0]
523 return polygon
526def polygon_scale(polygon):
527 """
528 For a Polygon object return the diagonal length of the AABB.
530 Parameters
531 ------------
532 polygon : shapely.geometry.Polygon
533 Source geometry
535 Returns
536 ------------
537 scale : float
538 Length of AABB diagonal
539 """
540 extents = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0)
541 scale = (extents**2).sum() ** 0.5
543 return scale
546def paths_to_polygons(paths, scale=None):
547 """
548 Given a sequence of connected points turn them into
549 valid shapely Polygon objects.
551 Parameters
552 -----------
553 paths : (n,) sequence
554 Of (m, 2) float closed paths
555 scale : float
556 Approximate scale of drawing for precision
558 Returns
559 -----------
560 polys : (p,) list
561 Filled with Polygon or None
563 """
564 polygons = [None] * len(paths)
565 for i, path in enumerate(paths):
566 if len(path) < 4:
567 # since the first and last vertices are identical in
568 # a closed loop a 4 vertex path is the minimum for
569 # non-zero area
570 continue
571 try:
572 polygon = Polygon(path)
573 if polygon.is_valid:
574 polygons[i] = polygon
575 else:
576 polygons[i] = repair_invalid(polygon, scale)
577 except ValueError:
578 # raised if a polygon is unrecoverable
579 continue
580 except BaseException:
581 log.error("unrecoverable polygon", exc_info=True)
582 polygons = np.array(polygons)
584 return polygons
587def sample(polygon, count, factor=1.5, max_iter=10, seed: Seed = None):
588 """
589 Use rejection sampling to generate random points inside a
590 polygon. Note that this function may return fewer or no
591 points, in particular if the polygon as very little area
592 compared to the area of the axis-aligned bounding box.
594 Parameters
595 -----------
596 polygon : shapely.geometry.Polygon
597 Polygon that will contain points
598 count : int
599 Number of points to return
600 factor : float
601 How many points to test per loop
602 max_iter : int
603 Maximum number of intersection checks is:
604 > count * factor * max_iter
605 seed : None or int
606 Seed for deterministic results, otherwise OS entropy.
608 Returns
609 -----------
610 hit : (n, 2) float
611 Random points inside polygon
612 where n <= count
613 """
614 # do batch point-in-polygon queries
615 from shapely import vectorized
617 # TODO : this should probably have some option to
618 # sample from the *oriented* bounding box which would
619 # make certain cases much, much more efficient.
621 # get size of bounding box
622 bounds = np.reshape(polygon.bounds, (2, 2))
623 extents = np.ptp(bounds, axis=0)
625 # how many points to check per loop iteration
626 per_loop = int(count * factor)
628 # start with some rejection sampling
629 random = random_generator(seed)
630 points = bounds[0] + extents * random.random((per_loop, 2))
631 # do the point in polygon test and append resulting hits
632 mask = vectorized.contains(polygon, *points.T)
633 hit = [points[mask]]
634 hit_count = len(hit[0])
635 # if our first non-looping check got enough samples exit
636 if hit_count >= count:
637 return hit[0][:count]
639 # if we have to do iterations loop here slowly
640 for _ in range(max_iter):
641 # generate points inside polygons AABB
642 points = (random.random((per_loop, 2)) * extents) + bounds[0]
643 # do the point in polygon test and append resulting hits
644 mask = vectorized.contains(polygon, *points.T)
645 hit.append(points[mask])
646 # keep track of how many points we've collected
647 hit_count += len(hit[-1])
648 # if we have enough points exit the loop
649 if hit_count > count:
650 break
652 # stack the hits into an (n,2) array and truncate
653 hit = np.vstack(hit)[:count]
655 return hit
658def repair_invalid(polygon, scale=None, rtol=0.5):
659 """
660 Given a shapely.geometry.Polygon, attempt to return a
661 valid version of the polygon through buffering tricks.
663 Parameters
664 -----------
665 polygon : shapely.geometry.Polygon
666 Source geometry
667 rtol : float
668 How close does a perimeter have to be
669 scale : float or None
670 For numerical precision reference
672 Returns
673 ----------
674 repaired : shapely.geometry.Polygon
675 Repaired polygon
677 Raises
678 ----------
679 ValueError
680 If polygon can't be repaired
681 """
682 if hasattr(polygon, "is_valid") and polygon.is_valid:
683 return polygon
685 # basic repair involves buffering the polygon outwards
686 # this will fix a subset of problems.
687 basic = polygon.buffer(tol.zero)
688 # if it returned multiple polygons check the largest
689 if hasattr(basic, "geoms"):
690 basic = basic.geoms[np.argmax([i.area for i in basic.geoms])]
692 # check perimeter of result against original perimeter
693 if basic.is_valid and np.isclose(basic.length, polygon.length, rtol=rtol):
694 return basic
696 if scale is None:
697 distance = 0.002 * np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).mean()
698 else:
699 distance = 0.002 * scale
701 # if there are no interiors, we can work with just the exterior
702 # ring, which is often more reliable
703 if len(polygon.interiors) == 0:
704 # try buffering the exterior of the polygon
705 # the interior will be offset by -tol.buffer
706 rings = polygon.exterior.buffer(distance).interiors
707 if len(rings) == 1:
708 # reconstruct a single polygon from the interior ring
709 recon = Polygon(shell=rings[0]).buffer(distance)
710 # check perimeter of result against original perimeter
711 if recon.is_valid and np.isclose(recon.length, polygon.length, rtol=rtol):
712 return recon
714 # try de-deuplicating the outside ring
715 points = np.array(polygon.exterior.coords)
716 # remove any segments shorter than tol.merge
717 # this is a little risky as if it was discretized more
718 # finely than 1-e8 it may remove detail
719 unique = np.append(True, (np.diff(points, axis=0) ** 2).sum(axis=1) ** 0.5 > 1e-8)
720 # make a new polygon with result
721 dedupe = Polygon(shell=points[unique])
722 # check result
723 if dedupe.is_valid and np.isclose(dedupe.length, polygon.length, rtol=rtol):
724 return dedupe
726 # buffer and unbuffer the whole polygon
727 buffered = polygon.buffer(distance).buffer(-distance)
728 # if it returned multiple polygons check the largest
729 if hasattr(buffered, "geoms"):
730 areas = np.array([b.area for b in buffered.geoms])
731 return buffered.geoms[areas.argmax()]
733 # check perimeter of result against original perimeter
734 if buffered.is_valid and np.isclose(buffered.length, polygon.length, rtol=rtol):
735 log.debug("Recovered invalid polygon through double buffering")
736 return buffered
738 raise ValueError("unable to recover polygon!")
741def projected(
742 mesh,
743 normal,
744 origin=None,
745 ignore_sign=True,
746 rpad=1e-5,
747 apad=None,
748 tol_dot=1e-10,
749 precise: bool = False,
750 precise_eps: float = 1e-10,
751):
752 """
753 Project a mesh onto a plane and then extract the polygon
754 that outlines the mesh projection on that plane.
756 Note that this will ignore back-faces, which is only
757 relevant if the source mesh isn't watertight.
759 Also padding: this generates a result by unioning the
760 polygons of multiple connected regions, which requires
761 the polygons be padded by a distance so that a polygon
762 union produces a single coherent result. This distance
763 is calculated as: `apad + (rpad * scale)`
765 Parameters
766 ----------
767 mesh : trimesh.Trimesh
768 Source geometry
769 normal : (3,) float
770 Normal to extract flat pattern along
771 origin : None or (3,) float
772 Origin of plane to project mesh onto
773 ignore_sign : bool
774 Allow a projection from the normal vector in
775 either direction: this provides a substantial speedup
776 on watertight meshes where the direction is irrelevant
777 but if you have a triangle soup and want to discard
778 backfaces you should set this to False.
779 rpad : float
780 Proportion to pad polygons by before unioning
781 and then de-padding result by to avoid zero-width gaps.
782 apad : float
783 Absolute padding to pad polygons by before unioning
784 and then de-padding result by to avoid zero-width gaps.
785 tol_dot : float
786 Tolerance for discarding on-edge triangles.
787 precise : bool
788 Use the precise projection computation using shapely.
789 precise_eps : float
790 Tolerance for precise triangle checks.
792 Returns
793 ----------
794 projected : shapely.geometry.Polygon or None
795 Outline of source mesh
797 Raises
798 ---------
799 ValueError
800 If max_regions is exceeded
801 """
802 # make sure normal is a unitized copy
803 normal = np.array(normal, dtype=np.float64)
804 normal /= np.linalg.norm(normal)
806 # the projection of each face normal onto facet normal
807 dot_face = np.dot(normal, mesh.face_normals.T)
808 if ignore_sign:
809 # for watertight mesh speed up projection by handling side with less faces
810 # check if face lies on front or back of normal
811 front = dot_face > tol_dot
812 back = dot_face < -tol_dot
813 # divide the mesh into front facing section and back facing parts
814 # and discard the faces perpendicular to the axis.
815 # since we are doing a unary_union later we can use the front *or*
816 # the back so we use which ever one has fewer triangles
817 # we want the largest nonzero group
818 count = np.array([front.sum(), back.sum()])
819 if count.min() == 0:
820 # if one of the sides has zero faces we need the other
821 pick = count.argmax()
822 else:
823 # otherwise use the normal direction with the fewest faces
824 pick = count.argmin()
825 # use the picked side
826 side = [front, back][pick]
827 else:
828 # if explicitly asked to care about the sign
829 # only handle the front side of normal
830 side = dot_face > tol_dot
832 # subset the adjacency pairs to ones which have both faces included
833 # on the side we are currently looking at
834 adjacency_check = side[mesh.face_adjacency].all(axis=1)
835 adjacency = mesh.face_adjacency[adjacency_check]
837 # transform from the mesh frame in 3D to the XY plane
838 to_2D = geometry.plane_transform(origin=origin, normal=normal)
839 # transform mesh vertices to 2D and clip the zero Z
840 vertices_2D = transform_points(mesh.vertices, to_2D)[:, :2]
842 if precise:
843 # precise mode just unions triangles as one shapely
844 # polygon per triangle which historically has been very slow
845 # but it is more defensible intellectually
846 faces = mesh.faces[side]
847 # round the 2D vertices with slightly more precision
848 # than our final dilate-erode cleanup
849 digits = int(np.abs(np.log10(precise_eps)) + 2)
850 rounded = np.round(vertices_2D, digits)
851 # get the triangles as closed 4-vertex polygons
852 triangles = rounded[np.column_stack((faces, faces[:, :1]))]
853 # do a check for exactly-degenerate triangles where any two
854 # vertices are exactly identical which means the triangle has
855 # zero area
856 valid = ~(triangles[:, [0, 0, 2]] == triangles[:, [1, 2, 1]]).all(axis=2).any(
857 axis=1
858 )
859 # union the valid triangles and then dilate-erode to clean up
860 # any holes or defects smaller than precise_eps
861 return (
862 ops.unary_union([Polygon(f) for f in triangles[valid]])
863 .buffer(precise_eps)
864 .buffer(-precise_eps)
865 )
867 # a sequence of face indexes that are connected
868 face_groups = graph.connected_components(adjacency, nodes=np.nonzero(side)[0])
870 # reshape edges into shape length of faces for indexing
871 edges = mesh.edges_sorted.reshape((-1, 6))
873 polygons = []
874 for faces in face_groups:
875 # index edges by face then shape back to individual edges
876 edge = edges[faces].reshape((-1, 2))
877 # edges that occur only once are on the boundary
878 group = grouping.group_rows(edge, require_count=1)
879 # turn each region into polygons
880 polygons.extend(edges_to_polygons(edges=edge[group], vertices=vertices_2D))
882 padding = 0.0
883 if apad is not None:
884 # set padding by absolute value
885 padding += float(apad)
886 if rpad is not None:
887 # get the 2D scale as the longest side of the AABB
888 scale = np.ptp(vertices_2D, axis=0).max()
889 # apply the scale-relative padding
890 padding += float(rpad) * scale
892 # if there is only one region we don't need to run a union
893 elif len(polygons) == 1:
894 return polygons[0]
895 elif len(polygons) == 0:
896 return None
898 # in my tests this was substantially faster than `shapely.ops.unary_union`
899 reduced = reduce_cascade(lambda a, b: a.union(b), polygons)
901 # can be None
902 if reduced is not None:
903 return reduced.buffer(padding).buffer(-padding)
906def second_moments(polygon: Polygon, return_centered=False):
907 """
908 Calculate the second moments of area of a polygon
909 from the boundary.
911 Parameters
912 ------------
913 polygon : shapely.geometry.Polygon
914 Closed polygon.
915 return_centered : bool
916 Get second moments for a frame with origin at the centroid
917 and perform a principal axis transformation.
919 Returns
920 ----------
921 moments : (3,) float
922 The values of `[Ixx, Iyy, Ixy]`
923 principal_moments : (2,) float
924 Principal second moments of inertia: `[Imax, Imin]`
925 Only returned if `centered`.
926 alpha : float
927 Angle by which the polygon needs to be rotated, so the
928 principal axis align with the X and Y axis.
929 Only returned if `centered`.
930 transform : (3, 3) float
931 Transformation matrix which rotates the polygon by alpha.
932 Only returned if `centered`.
933 """
935 transform = np.eye(3)
936 if return_centered:
937 # calculate centroid and move polygon
938 transform[:2, 2] = -np.array(polygon.centroid.coords)
939 polygon = transform_polygon(polygon, transform)
941 # start with the exterior
942 coords = np.array(polygon.exterior.coords)
943 # shorthand the coordinates
944 x1, y1 = np.vstack((coords[-1], coords[:-1])).T
945 x2, y2 = coords.T
946 # do vectorized operations
947 v = x1 * y2 - x2 * y1
948 Ixx = np.sum(v * (y1 * y1 + y1 * y2 + y2 * y2)) / 12.0
949 Iyy = np.sum(v * (x1 * x1 + x1 * x2 + x2 * x2)) / 12.0
950 Ixy = np.sum(v * (x1 * y2 + 2 * x1 * y1 + 2 * x2 * y2 + x2 * y1)) / 24.0
952 for interior in polygon.interiors:
953 coords = np.array(interior.coords)
954 # shorthand the coordinates
955 x1, y1 = np.vstack((coords[-1], coords[:-1])).T
956 x2, y2 = coords.T
957 # do vectorized operations
958 v = x1 * y2 - x2 * y1
959 Ixx -= np.sum(v * (y1 * y1 + y1 * y2 + y2 * y2)) / 12.0
960 Iyy -= np.sum(v * (x1 * x1 + x1 * x2 + x2 * x2)) / 12.0
961 Ixy -= np.sum(v * (x1 * y2 + 2 * x1 * y1 + 2 * x2 * y2 + x2 * y1)) / 24.0
963 moments = [Ixx, Iyy, Ixy]
965 if not return_centered:
966 return moments
968 # get the principal moments
969 root = np.sqrt(((Iyy - Ixx) / 2.0) ** 2 + Ixy**2)
970 Imax = (Ixx + Iyy) / 2.0 + root
971 Imin = (Ixx + Iyy) / 2.0 - root
972 principal_moments = [Imax, Imin]
974 # do the principal axis transform
975 if np.isclose(Ixy, 0.0, atol=1e-12):
976 alpha = 0
977 elif np.isclose(Ixx, Iyy):
978 # prevent division by 0
979 alpha = 0.25 * np.pi
980 else:
981 alpha = 0.5 * np.arctan(2.0 * Ixy / (Ixx - Iyy))
983 # construct transformation matrix
984 cos_alpha = np.cos(alpha)
985 sin_alpha = np.sin(alpha)
987 transform[0, 0] = cos_alpha
988 transform[1, 1] = cos_alpha
989 transform[0, 1] = -sin_alpha
990 transform[1, 0] = sin_alpha
992 return moments, principal_moments, alpha, transform