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