Skip to content

Commit f4f0cd7

Browse files
chriswmackeyChris Mackey
authored and
Chris Mackey
committed
feat(polygon): Add a method to generate common axes for alignment
1 parent 660c72a commit f4f0cd7

File tree

3 files changed

+190
-10
lines changed

3 files changed

+190
-10
lines changed

ladybug_geometry/geometry2d/polygon.py

+173-10
Original file line numberDiff line numberDiff line change
@@ -1124,14 +1124,19 @@ def to_array(self):
11241124
return tuple(pt.to_array() for pt in self.vertices)
11251125

11261126
def _to_bool_poly(self):
1127-
"""A hidden method used to translate the Polygon2D to a BooleanPolygon.
1128-
1129-
This is necessary before performing any boolean operations with
1130-
the polygon.
1131-
"""
1127+
"""Translate the Polygon2D to a BooleanPolygon."""
11321128
b_pts = (pb.BooleanPoint(pt.x, pt.y) for pt in self.vertices)
11331129
return pb.BooleanPolygon([b_pts])
11341130

1131+
def _to_snapped_bool_poly(self, snap_ref_polygon, tolerance):
1132+
"""Snap a Polygon2D to this one and translate it to a BooleanPolygon.
1133+
1134+
This is necessary to ensure that boolean operations will succeed between
1135+
two polygons.
1136+
"""
1137+
new_poly = snap_ref_polygon.snap_to_polygon(self, tolerance)
1138+
return new_poly._to_bool_poly()
1139+
11351140
@staticmethod
11361141
def _from_bool_poly(bool_polygon):
11371142
"""Get a list of Polygon2D from a BooleanPolygon object."""
@@ -1159,7 +1164,9 @@ def boolean_union(self, polygon, tolerance):
11591164
A list of Polygon2D representing the union of the two polygons.
11601165
"""
11611166
result = pb.union(
1162-
self._to_bool_poly(), polygon._to_bool_poly(), tolerance)
1167+
self._to_bool_poly(),
1168+
polygon._to_snapped_bool_poly(self, tolerance),
1169+
tolerance / 100)
11631170
return Polygon2D._from_bool_poly(result)
11641171

11651172
def boolean_intersect(self, polygon, tolerance):
@@ -1176,7 +1183,9 @@ def boolean_intersect(self, polygon, tolerance):
11761183
Will be an empty list if no overlap exists between the polygons.
11771184
"""
11781185
result = pb.intersect(
1179-
self._to_bool_poly(), polygon._to_bool_poly(), tolerance)
1186+
self._to_bool_poly(),
1187+
polygon._to_snapped_bool_poly(self, tolerance),
1188+
tolerance / 100)
11801189
return Polygon2D._from_bool_poly(result)
11811190

11821191
def boolean_difference(self, polygon, tolerance):
@@ -1195,7 +1204,9 @@ def boolean_difference(self, polygon, tolerance):
11951204
is no overlap between the polygons.
11961205
"""
11971206
result = pb.difference(
1198-
self._to_bool_poly(), polygon._to_bool_poly(), tolerance)
1207+
self._to_bool_poly(),
1208+
polygon._to_snapped_bool_poly(self, tolerance),
1209+
tolerance / 100)
11991210
return Polygon2D._from_bool_poly(result)
12001211

12011212
def boolean_xor(self, polygon, tolerance):
@@ -1224,7 +1235,9 @@ def boolean_xor(self, polygon, tolerance):
12241235
in the two.
12251236
"""
12261237
result = pb.xor(
1227-
self._to_bool_poly(), polygon._to_bool_poly(), tolerance)
1238+
self._to_bool_poly(),
1239+
polygon._to_snapped_bool_poly(self, tolerance),
1240+
tolerance / 100)
12281241
return Polygon2D._from_bool_poly(result)
12291242

12301243
@staticmethod
@@ -1320,7 +1333,9 @@ def boolean_split(polygon1, polygon2, tolerance):
13201333
makes a split version of polygon2.
13211334
"""
13221335
int_result, poly1_result, poly2_result = pb.split(
1323-
polygon1._to_bool_poly(), polygon2._to_bool_poly(), tolerance)
1336+
polygon1._to_bool_poly(),
1337+
polygon2._to_snapped_bool_poly(polygon1, tolerance),
1338+
tolerance / 100)
13241339
intersection = Polygon2D._from_bool_poly(int_result)
13251340
poly1_difference = Polygon2D._from_bool_poly(poly1_result)
13261341
poly2_difference = Polygon2D._from_bool_poly(poly2_result)
@@ -1724,6 +1739,154 @@ def gap_crossing_boundary(polygons, min_separation, tolerance):
17241739

17251740
return closed_polys
17261741

1742+
@staticmethod
1743+
def common_axes(
1744+
polygons, direction, min_distance, merge_distance, fraction_to_keep,
1745+
angle_tolerance
1746+
):
1747+
"""Get LineSegment2Ds for the most common axes across a set of Polygon2Ds.
1748+
1749+
This is often useful as a step before aligning a set of polygons to these
1750+
common axes.
1751+
1752+
Args:
1753+
polygons: A list or tuple of Polygon2D objects for which common axes
1754+
will be evaluated.
1755+
direction: A Vector2D object to represent the direction in which the
1756+
common axes will be evaluated and generated
1757+
min_distance: The minimum distance at which common axes will be evaluated.
1758+
This value should typically be a little larger than the model
1759+
tolerance (eg. 5 to 20 times the tolerance) in order to ensure that
1760+
possible common axes across the input polygons are not missed.
1761+
merge_distance: The distance at which common axes next to one another
1762+
will be merged into a single axis. This should typically be 2-3
1763+
times the min_distance in order to avoid generating several axes
1764+
that are immediately adjacent to one another. When using this
1765+
method to generate axes for alignment, this merge_distance should
1766+
be in the range of the alignment distance.
1767+
fraction_to_keep: A number between 0 and 1 representing the fraction of
1768+
all possible axes that will be kept in the result. Depending on
1769+
the complexity of the input geometry, something between 0.1 and
1770+
0.3 is typically appropriate.
1771+
angle_tolerance: The max angle difference in radians that the polygon
1772+
segments direction can differ from the input direction before the
1773+
segments are not factored into this calculation of common axes.
1774+
1775+
Returns:
1776+
A list of LineSegment2D objects for the common axes across the
1777+
input polygons.
1778+
"""
1779+
# gather the relevant segments of the input polygons
1780+
min_ang, max_ang = angle_tolerance, math.pi - angle_tolerance
1781+
rel_segs = []
1782+
for p_gon in polygons:
1783+
for seg in p_gon.segments:
1784+
try:
1785+
s_ang = direction.angle(seg.v)
1786+
if s_ang < min_ang or s_ang > max_ang:
1787+
rel_segs.append(seg)
1788+
except ZeroDivisionError: # zero length segment to ignore
1789+
continue
1790+
if len(rel_segs) == 0:
1791+
return [] # none of the polygon segments are relevant in the direction
1792+
1793+
# determine the extents around the polygons and the input direction
1794+
gen_vec = direction.rotate(math.pi / 2)
1795+
axis_angle = Vector2D(0, 1).angle_counterclockwise(gen_vec)
1796+
orient_poly = polygons
1797+
if axis_angle != 0: # rotate geometry to the bounding box
1798+
cpt = polygons[0].vertices[0]
1799+
orient_poly = [pl.rotate(-axis_angle, cpt) for pl in polygons]
1800+
xx = Polygon2D._bounding_domain_x(orient_poly)
1801+
yy = Polygon2D._bounding_domain_y(orient_poly)
1802+
min_pt = Point2D(xx[0], yy[0])
1803+
max_pt = Point2D(xx[1], yy[1])
1804+
if axis_angle != 0: # rotate the points back
1805+
min_pt = min_pt.rotate(axis_angle, cpt)
1806+
max_pt = max_pt.rotate(axis_angle, cpt)
1807+
1808+
# generate all possible axes from the extents and min_distance
1809+
axis_vec = direction.normalize() * (xx[1] - xx[0])
1810+
incr_vec = gen_vec.normalize() * (min_distance)
1811+
current_pt = min_pt
1812+
current_dist, max_dist = 0, yy[1] - yy[0]
1813+
all_axes = []
1814+
while current_dist < max_dist:
1815+
axis = LineSegment2D(current_pt, axis_vec)
1816+
all_axes.append(axis)
1817+
current_pt = current_pt.move(incr_vec)
1818+
current_dist += min_distance
1819+
1820+
# evaluate the axes based on how many relevant segments they are next to
1821+
mid_pts = [seg.midpoint for seg in rel_segs]
1822+
rel_axes, axes_value = [], []
1823+
for axis in all_axes:
1824+
axis_val = 0
1825+
for pt in mid_pts:
1826+
if axis.distance_to_point(pt) <= merge_distance:
1827+
axis_val += 1
1828+
if axis_val != 0:
1829+
rel_axes.append(axis)
1830+
axes_value.append(axis_val)
1831+
if len(rel_axes) == 0:
1832+
return [] # none of the generated axes are relevant
1833+
1834+
# sort the axes by how relevant they are to segments and keep a certain fraction
1835+
count_to_keep = int(len(all_axes) * fraction_to_keep)
1836+
i_to_keep = [i for _, i in sorted(zip(axes_value, range(len(rel_axes))))]
1837+
i_to_keep.reverse()
1838+
if count_to_keep == 0:
1839+
count_to_keep = 1
1840+
elif count_to_keep > len(i_to_keep):
1841+
count_to_keep = len(i_to_keep)
1842+
rel_i = i_to_keep[:count_to_keep]
1843+
rel_i.sort()
1844+
rel_axes = [rel_axes[i] for i in rel_i]
1845+
1846+
# group the axes by proximity
1847+
last_ax = rel_axes[0]
1848+
axes_groups = [[last_ax]]
1849+
for axis in rel_axes[1:]:
1850+
if axis.p.distance_to_point(last_ax.p) <= merge_distance:
1851+
axes_groups[-1].append(axis)
1852+
else: # start a new group
1853+
axes_groups.append([axis])
1854+
last_ax = axis
1855+
1856+
# average the line segments that are within the merge_distance of one another
1857+
final_axes = []
1858+
for ax_group in axes_groups:
1859+
if len(ax_group) == 1:
1860+
final_axes.append(ax_group[0])
1861+
else:
1862+
st_pt_x = (ax_group[0].p1.x + ax_group[-1].p1.x) / 2
1863+
st_pt_y = (ax_group[0].p1.y + ax_group[-1].p1.y) / 2
1864+
avg_ax = LineSegment2D(Point2D(st_pt_x, st_pt_y), axis_vec)
1865+
final_axes.append(avg_ax)
1866+
return final_axes
1867+
1868+
@staticmethod
1869+
def _bounding_domain_x(geometries):
1870+
"""Get minimum and maximum X coordinates of multiple polygons."""
1871+
min_x, max_x = geometries[0].min.x, geometries[0].max.x
1872+
for geom in geometries[1:]:
1873+
if geom.min.x < min_x:
1874+
min_x = geom.min.x
1875+
if geom.max.x > max_x:
1876+
max_x = geom.max.x
1877+
return min_x, max_x
1878+
1879+
@staticmethod
1880+
def _bounding_domain_y(geometries):
1881+
"""Get minimum and maximum Y coordinates of multiple polygons."""
1882+
min_y, max_y = geometries[0].min.y, geometries[0].max.y
1883+
for geom in geometries[1:]:
1884+
if geom.min.y < min_y:
1885+
min_y = geom.min.y
1886+
if geom.max.y > max_y:
1887+
max_y = geom.max.y
1888+
return min_y, max_y
1889+
17271890
def _point_in_polygon(self, tolerance):
17281891
"""Get a Point2D that is always reliably inside this Polygon2D.
17291892

tests/json/polygons_for_alignment.json

+1
Large diffs are not rendered by default.

tests/polygon2d_test.py

+16
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,22 @@ def test_boolean_split():
10791079
assert len(poly2_dif[0].vertices) == 7
10801080

10811081

1082+
def test_common_axes():
1083+
"""Test the common_axes method"""
1084+
geo_file = './tests/json/polygons_for_alignment.json'
1085+
with open(geo_file, 'r') as fp:
1086+
geo_dict = json.load(fp)
1087+
polygons = [Polygon2D.from_dict(p) for p in geo_dict]
1088+
1089+
axes = Polygon2D.common_axes(
1090+
polygons, Vector2D(1, 0),min_distance=0.15, merge_distance=0.3,
1091+
fraction_to_keep=0.2, angle_tolerance=math.pi / 180)
1092+
1093+
assert len(axes) == 16
1094+
for item in axes:
1095+
assert isinstance(item, LineSegment2D)
1096+
1097+
10821098
def test_joined_intersected_boundary():
10831099
geo_file = './tests/json/polygons_for_joined_boundary.json'
10841100
with open(geo_file, 'r') as fp:

0 commit comments

Comments
 (0)