Skip to content

Commit 934fb8e

Browse files
chriswmackeyChris Mackey
authored andcommitted
fix(polygon): Add method to offset polygons
1 parent c75aa30 commit 934fb8e

File tree

6 files changed

+82
-16
lines changed

6 files changed

+82
-16
lines changed

ladybug_geometry/geometry2d/polygon.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,60 @@ def scale(self, factor, origin=None):
447447
else:
448448
return Polygon2D(tuple(pt.scale(factor, origin) for pt in self.vertices))
449449

450+
def offset(self, distance, check_intersection=False):
451+
"""Offset the polygon by a given distance inwards or outwards.
452+
453+
Note that the resulting shape may be self-intersecting if the distance
454+
is large enough and the is_self_intersecting property may be used to identify
455+
these shapes.
456+
457+
Args:
458+
distance: The distance inwards that the polygon will be offset.
459+
Positive values will be offset inwards while negative ones
460+
will be offset outwards.
461+
check_intersection: A boolean to note whether the resulting operation
462+
should be checked for self intersection and, if so, None will be
463+
returned instead of the mis-shaped polygon.
464+
"""
465+
# make sure the offset is not zero
466+
if distance == 0:
467+
return self
468+
469+
# loop through the vertices and get the new offset vectors
470+
move_vecs, max_i = [], len(self._vertices) - 1
471+
for i, pt in enumerate(self._vertices):
472+
v1 = self._vertices[i - 1] - pt
473+
end_i = i + 1 if i != max_i else 0
474+
v2 = self._vertices[end_i] - pt
475+
ang = v1.angle_clockwise(v2) / 2 if not self.is_clockwise \
476+
else v1.angle_counterclockwise(v2) / 2
477+
m_vec = v1.rotate(-ang).normalize() if not self.is_clockwise \
478+
else v1.rotate(ang).normalize()
479+
m_dist = distance / math.sin(ang)
480+
m_vec = m_vec * m_dist
481+
move_vecs.append(m_vec)
482+
483+
# move the vertices by the offset to create the new Polygon2D
484+
new_pts = tuple(pt.move(m_vec) for pt, m_vec in zip(self._vertices, move_vecs))
485+
new_poly = Polygon2D(new_pts)
486+
487+
# check for self intersection between the moving vectors if requested
488+
if check_intersection:
489+
poly_segs = new_poly.segments
490+
_segs = [LineSegment2D(p, v) for p, v in zip(self._vertices, move_vecs)]
491+
_skip = (0, len(_segs) - 1)
492+
_other_segs = [x for j, x in enumerate(poly_segs) if j not in _skip]
493+
for _oth_s in _other_segs:
494+
if _segs[0].intersect_line_ray(_oth_s) is not None: # intersection!
495+
return None
496+
for i, _s in enumerate(_segs[1 : len(_segs)]):
497+
_skip = (i, i + 1)
498+
_other_segs = [x for j, x in enumerate(poly_segs) if j not in _skip]
499+
for _oth_s in _other_segs:
500+
if _s.intersect_line_ray(_oth_s) is not None: # intersection!
501+
return None
502+
return new_poly
503+
450504
def intersect_line_ray(self, line_ray):
451505
"""Get the intersections between this polygon and a Ray2D or LineSegment2D.
452506

ladybug_geometry/geometry3d/cone.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def volume(self):
109109
return 1 / 3 * math.pi * self.radius ** 2 * self.height
110110

111111
@property
112-
def base(self):
112+
def base(self):
113113
"""Get an Arc3D representing the circular base of the cone."""
114114
if self._base is None:
115115
plane = Plane(self.axis.reverse(), self.vertex + self.axis)

ladybug_geometry/geometry3d/face.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,7 @@ def is_geometrically_equivalent(self, face, tolerance):
605605
face: Another face for which geometric equivalency will be tested.
606606
tolerance: The minimum difference between the coordinate values of two
607607
vertices at which they can be considered geometrically equivalent.
608+
608609
Returns:
609610
True if geometrically equivalent. False if not geometrically equivalent.
610611
"""

ladybug_geometry/geometry3d/plane.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def __init__(self, n=Vector3D(0, 0, 1), o=Point3D(0, 0, 0), x=None):
5757
"Expected Vector3D for plane X-axis. Got {}.".format(type(x))
5858
x = x.normalize()
5959
assert abs(self._n.x * x.x + self._n.y * x.y + self._n.z * x.z) < 1e-2, \
60-
'Plane X-axis and normal vector are not orthagonal. Got angle of {} ' \
60+
'Plane X-axis and normal vector are not orthogonal. Got angle of {} ' \
6161
'degrees between them.'.format(math.degrees(self._n.angle(x)))
6262
self._x = x
6363
self._y = self._n.cross(self._x)

ladybug_geometry/triangulation.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@
1010
The version here is based off of the JavaScript earcut 2.1.1 release, and is
1111
functionally identical.
1212
"""
13-
import math
1413

1514

1615
def earcut(data, hole_indices=None, dim=2):
1716
"""Triangulate a list of vertices that make up a shape, either with or without holes.
18-
17+
1918
Args:
2019
data: A flat array of vertex coordinates like [x0,y0, x1,y1, x2,y2, ...].
2120
hole_indices: A flat array of the starting indices for each hole. For example,
@@ -29,7 +28,7 @@ def earcut(data, hole_indices=None, dim=2):
2928
"""
3029
dim = dim or 2
3130
hasHoles = hole_indices and len(hole_indices)
32-
outerLen = hole_indices[0] * dim if hasHoles else len(data)
31+
outerLen = hole_indices[0] * dim if hasHoles else len(data)
3332
outerNode = _linked_list(data, 0, outerLen, dim, True)
3433
triangles = []
3534

@@ -161,7 +160,7 @@ def _earcut_linked(ear, triangles, dim, minX, minY, size, _pass=None):
161160

162161
_remove_node(ear)
163162

164-
# skipping the next vertice leads to less sliver triangles
163+
# skipping the next vertex leads to less sliver triangles
165164
ear = next.next
166165
stop = next.next
167166

@@ -194,7 +193,7 @@ def _is_ear(ear):
194193
c = ear.next
195194

196195
if _area(a, b, c) >= 0:
197-
return False # reflex, can't be an ear
196+
return False # reflex, can't be an ear
198197

199198
# now make sure we don't have other points inside the potential ear
200199
p = ear.next.next
@@ -215,7 +214,7 @@ def _is_ear_hashed(ear, minX, minY, size):
215214
c = ear.next
216215

217216
if _area(a, b, c) >= 0:
218-
return False # reflex, can't be an ear
217+
return False # reflex, can't be an ear
219218

220219
# triangle bbox; min & max are calculated like this for speed
221220
minTX = (a.x if a.x < c.x else c.x) if a.x < b.x else (b.x if b.x < c.x else c.x)
@@ -318,7 +317,7 @@ def _eliminate_holes(data, hole_indices, outerNode, dim):
318317

319318
for i in range(len(hole_indices)):
320319
start = hole_indices[i] * dim
321-
end = hole_indices[i + 1] * dim if i < _len - 1 else len(data)
320+
end = hole_indices[i + 1] * dim if i < _len - 1 else len(data)
322321
_list = _linked_list(data, start, end, dim, False)
323322

324323
if (_list == _list.next):
@@ -338,7 +337,7 @@ def _eliminate_holes(data, hole_indices, outerNode, dim):
338337

339338
def _eliminate_hole(hole, outerNode):
340339
"""Find a bridge between vertices that connects hole with an outer ring.
341-
340+
342341
Return a shape with the hole linked into it."""
343342
outerNode = _find_hole_bridge(hole, outerNode)
344343
if outerNode:
@@ -379,7 +378,7 @@ def _find_hole_bridge(hole, outerNode):
379378
return None
380379

381380
if hx == qx:
382-
return m.prev # hole touches outer segment; pick lower endpoint
381+
return m.prev # hole touches outer segment; pick lower endpoint
383382

384383
# check points inside the triangle of hole point, segment intersection and endpoint
385384
# if there are no points found, we have a valid connection
@@ -400,7 +399,7 @@ def _find_hole_bridge(hole, outerNode):
400399
if hx >= p.x and p.x >= mx and \
401400
_point_in_triangle(hx_or_qx, hy, mx, my, qx_or_hx, hy, p.x, p.y):
402401
try:
403-
tan = abs(hy - p.y) / (hx - p.x) # tangential
402+
tan = abs(hy - p.y) / (hx - p.x) # tangential
404403
except ZeroDivisionError:
405404
break
406405

@@ -422,7 +421,7 @@ def _index_curve(start, minX, minY, size):
422421
while do or p != start:
423422
do = False
424423

425-
if p.z == None:
424+
if p.z is None:
426425
p.z = _z_order(p.x, p.y, minX, minY, size)
427426

428427
p.prevZ = p.prev
@@ -585,8 +584,8 @@ def _intersects_polygon(a, b):
585584

586585
while do or p != a:
587586
do = False
588-
if (p.i != a.i and p.next.i != a.i and p.i != b.i and p.next.i != b.i and \
589-
_intersects(p, p.next, a, b)):
587+
init_int = p.i != a.i and p.next.i != a.i and p.i != b.i and p.next.i != b.i
588+
if init_int and _intersects(p, p.next, a, b):
590589
return True
591590

592591
p = p.next
@@ -691,7 +690,7 @@ def __init__(self, i, x, y):
691690
self.x = x
692691
self.y = y
693692

694-
# previous and next vertice nodes in a polygon ring
693+
# previous and next vertex nodes in a polygon ring
695694
self.prev = None
696695
self.next = None
697696

tests/polygon2d_test.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,18 @@ def test_reverse():
274274
assert polygon.is_self_intersecting == new_polygon.is_self_intersecting
275275

276276

277+
def test_offset():
278+
"""Test the offset method."""
279+
pts_1 = (Point2D(0, 0), Point2D(2, 0), Point2D(2, 2), Point2D(0, 2))
280+
polygon = Polygon2D(pts_1)
281+
new_polygon = polygon.offset(0.5)
282+
283+
assert 0.99 < new_polygon.area < 1.01
284+
assert not new_polygon.is_clockwise
285+
assert polygon.is_convex == new_polygon.is_convex
286+
assert polygon.is_self_intersecting == new_polygon.is_self_intersecting
287+
288+
277289
def test_move():
278290
"""Test the Polygon2D move method."""
279291
pts = (Point2D(0, 0), Point2D(2, 0), Point2D(2, 2), Point2D(0, 2))

0 commit comments

Comments
 (0)