Skip to content

Commit

Permalink
fix(polysplit): Add final fixes to ensure all edge cases caught
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswmackey authored and Chris Mackey committed Jun 21, 2024
1 parent d8307d8 commit a73923f
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 139 deletions.
252 changes: 148 additions & 104 deletions ladybug_geometry_polyskel/polygraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,67 +383,151 @@ def intersect_graph_with_segment(self, segment):
self.add_node(n1.pt, [n2.pt], exterior=False)
self.add_node(n2.pt, [n1.pt], exterior=False)

def min_cycle(self, base_node, goal_node, ccw_only=False):
"""Identify the shortest interior cycle between two exterior nodes.
Args:
base_node: The first exterior node of the edge.
goal_node: The end exterior node of the cycle that, together with
the base_node, constitutes an exterior edge.
ccw_only: A boolean to note whether the search should be limited
to the counter-clockwise direction only. (Default: False).
Returns:
A list of nodes that form a polygon if the cycle exists, else None.
"""
# set up a queue for exploring the graph
explored = []
queue = [[base_node]]
orig_dir = base_node.pt - goal_node.pt # yields a vector
# loop to traverse the graph with the help of the queue
while queue:
path = queue.pop(0)
node = path[-1]
# make sure that the current node has not been visited
if node not in explored:
prev_dir = node.pt - path[-2].pt if len(path) > 1 else orig_dir
# iterate over the neighbors to determine relevant nodes
rel_neighbors, rel_angles = [], []
for neighbor in node.adj_lst:
if neighbor == goal_node: # the shortest path was found!
path.append(goal_node)
return path
elif neighbor.exterior:
continue # don't traverse the graph exterior
edge_dir = neighbor.pt - node.pt
cw_angle = prev_dir.angle_clockwise(edge_dir * -1)
if not (1e-5 < cw_angle < (2 * math.pi) - 1e-5):
continue # prevent back-tracking along the search
rel_neighbors.append(neighbor)
rel_angles.append(cw_angle)
# sort the neighbors by clockwise angle
if len(rel_neighbors) > 1:
rel_neighbors = [n for _, n in sorted(zip(rel_angles, rel_neighbors),
key=lambda pair: pair[0])]
# add the relevant neighbors to the path and the queue
if ccw_only:
new_path = list(path)
new_path.append(rel_neighbors[0])
queue.append(new_path)
else: # add all neighbors to the search
for neighbor in rel_neighbors:
new_path = list(path)
new_path.append(neighbor)
queue.append(new_path)
explored.append(node)
# if we reached the end of the queue, then no path was found
return None

def exterior_cycle(self, cycle_root):
"""Computes exterior boundary from a given node.
This method assumes that exterior edges are naked (unidirectional) and
interior edges are bidirectional.
Args:
cycle_root: Starting _Node in exterior cycle.
Returns:
List of nodes on exterior if a cycle exists, else None.
"""
# Get the first exterior edge
curr_node = cycle_root
next_node = PolygonDirectedGraph.next_exterior_node(curr_node)
if not next_node:
return None

# loop through the cycle until we get it all or run out of points
max_iter = self.node_count + 1 # maximum length a cycle can be
ext_cycle = [curr_node]
iter_count = 0
while next_node.key != cycle_root.key:
ext_cycle.append(next_node)
next_node = PolygonDirectedGraph.next_exterior_node(next_node)
if not next_node:
return None # we have hit a dead end in the cycle
iter_count += 1
if iter_count > max_iter:
break # we have gotten stuck in a loop

return ext_cycle

def exterior_cycles(self):
"""Get a list of lists where each sub-list is an exterior cycle of Nodes."""
exterior_poly_lst = [] # list to store cycles
exterior_check = {} # dictionary to note explored exterior nodes
explored_nodes = set() # set to note explored exterior nodes
max_iter = self.node_count + 1 # maximum length a cycle can be

# loop through all of the nodes of the graph and find cycles
for root_node in self.ordered_nodes:
# make a note that the current node has been explored
exterior_check[root_node.key] = None
explored_nodes.add(root_node.key) # mark the node as explored
# get next exterior adjacent node and check that it's valid
next_node = self.next_exterior_node(root_node)
is_valid = (next_node is not None) and (next_node.key not in exterior_check)
next_node = self.next_exterior_node(root_node) # mark the node as explored
is_valid = (next_node is not None) and (next_node.key not in explored_nodes)
if not is_valid:
continue
# make a note that the next node has been explored
exterior_check[next_node.key] = None
explored_nodes.add(next_node.key)

# traverse the loop of points until we get back to start or hit a dead end
exterior_poly = [root_node]
prev_node = root_node
iter_count = 0
while next_node.key != root_node.key:
exterior_poly.append(next_node)
exterior_check[next_node.key] = None # mark the node as explored
following_node = self.next_exterior_node(next_node, prev_node)
explored_nodes.add(next_node.key) # mark the node as explored
follow_node = self.next_exterior_node_no_backtrack(
next_node, prev_node, explored_nodes)
prev_node = next_node # set as the previous node for the next step
next_node = following_node
next_node = follow_node
if next_node is None:
break # we have hit a dead end in the cycle
iter_count += 1
if iter_count > max_iter:
print('Extraction of core polygons hit an endless loop.')
break # we have gotten stuck in a loop
exterior_poly_lst.append(exterior_poly)

# return all of the exterior loops that were found
return exterior_poly_lst

@staticmethod
def is_edge_bidirect(node1, node2):
"""Are two nodes bidirectional.
Args:
node1: _Node object
node2: _Node object
Returns:
True if node1 and node2 are in each other's adjacency list,
else False.
"""
return node1.key in (n.key for n in node2.adj_lst) and \
node2.key in (n.key for n in node1.adj_lst)

@staticmethod
def next_exterior_node(node, previous_node=None):
"""Get the next exterior node adjacent to consumed node.
If there are adjacent nodes that are labeled as exterior, with True or
False defining the _Node.exterior property, the first of such nodes in
the adjacency list will be returned as the next one. Otherwise, the
bi-directionality will be used to determine whether the next node is
exterior.
def next_exterior_node_no_backtrack(node, previous_node, explored_nodes):
"""Get the next exterior node adjacent to the input node.
This method is similar to the next_exterior_node method but it includes
extra checks to handle intersections with 3 or more segments in the
graph exterior cycles. In these cases a set of previously explored_nodes
is used to ensure that no back-tracking happens over the search of the
network, which can lead to infinite looping through the graph. Furthermore,
the previous_node is used to select the pathway with the smallest angle
difference with the previous direction. This leads the result towards
minimal polygons with fewer self-intersecting loops.
Args:
node: A _Node object for which the next node will be returned.
previous_node: An optional _Node object for the node that came before
previous_node: A _Node object for the node that came before
the current one in the loop. This will be used in the event that
multiple exterior nodes are found connecting to the input node.
In this case, the exterior node with the smallest angle difference
Expand All @@ -463,9 +547,12 @@ def next_exterior_node(node, previous_node=None):
elif _next_node.exterior is None: # don't know if it's interior or exterior
# if user-assigned attribute isn't defined, check bi-directionality
if not PolygonDirectedGraph.is_edge_bidirect(node, _next_node):
if previous_node is None:
return _next_node # we don't need to check multiple nodes
next_nodes.append(_next_node)

# evaluate whether there is one obvious choice for the next node
if len(next_nodes) <= 1:
return next_nodes[0] if len(next_nodes) == 1 else None
next_nodes = [nn for nn in next_nodes if nn.key not in explored_nodes]
if len(next_nodes) <= 1:
return next_nodes[0] if len(next_nodes) == 1 else None

Expand All @@ -480,89 +567,46 @@ def next_exterior_node(node, previous_node=None):
return sorted_nodes[0] # return the node making the smallest angle

@staticmethod
def exterior_cycle(cycle_root):
"""Computes exterior boundary from a given node.
def next_exterior_node(node):
"""Get the next exterior node adjacent to consumed node.
This method assumes that exterior edges are naked (unidirectional) and
interior edges are bidirectional.
If there are adjacent nodes that are labeled as exterior, with True or
False defining the _Node.exterior property, the first of such nodes in
the adjacency list will be returned as the next one. Otherwise, the
bi-directionality will be used to determine whether the next node is
exterior.
Args:
cycle_root: Starting _Node in exterior cycle.
node: A _Node object for which the next node will be returned.
Returns:
List of nodes on exterior if a cycle exists, else None.
Next node that defines exterior edge, or None if all adjacencies are
bidirectional.
"""
# Get the first exterior edge
curr_node = cycle_root
next_node = PolygonDirectedGraph.next_exterior_node(curr_node)
if not next_node:
return None

ext_cycle = [curr_node]
while next_node.key != cycle_root.key:
ext_cycle.append(next_node)
next_node = PolygonDirectedGraph.next_exterior_node(next_node)
if not next_node:
return None

return ext_cycle
# loop through the adjacency and find an exterior node
for _next_node in node.adj_lst:
if _next_node.exterior: # user has labeled it as exterior; we're done!
return _next_node
elif _next_node.exterior is None: # don't know if it's interior or exterior
# if user-assigned attribute isn't defined, check bi-directionality
if not PolygonDirectedGraph.is_edge_bidirect(node, _next_node):
return _next_node
return None

@staticmethod
def min_cycle(base_node, goal_node, ccw_only=False):
"""Identify the shortest interior cycle between two exterior nodes.
def is_edge_bidirect(node1, node2):
"""Are two nodes bidirectional.
Args:
base_node: The first exterior node of the edge.
goal_node: The end exterior node of the cycle that, together with
the base_node, constitutes an exterior edge.
ccw_only: A boolean to note whether the search should be limited
to the counter-clockwise direction only. (Default: False).
node1: _Node object
node2: _Node object
Returns:
A list of nodes that form a polygon if the cycle exists, else None.
True if node1 and node2 are in each other's adjacency list,
else False.
"""
# set up a queue for exploring the graph
explored = []
queue = [[base_node]]
orig_dir = base_node.pt - goal_node.pt # yields a vector
# loop to traverse the graph with the help of the queue
while queue:
path = queue.pop(0)
node = path[-1]
# make sure that the current node has not been visited
if node not in explored:
prev_dir = node.pt - path[-2].pt if len(path) > 1 else orig_dir
# iterate over the neighbors to determine relevant nodes
rel_neighbors, rel_angles = [], []
for neighbor in node.adj_lst:
if neighbor == goal_node: # the shortest path was found!
path.append(goal_node)
return path
elif neighbor.exterior:
continue # don't traverse the graph exterior
edge_dir = neighbor.pt - node.pt
cw_angle = prev_dir.angle_clockwise(edge_dir * -1)
if not (1e-5 < cw_angle < (2 * math.pi) - 1e-5):
continue # prevent back-tracking along the search
rel_neighbors.append(neighbor)
rel_angles.append(cw_angle)
# sort the neighbors by clockwise angle
if len(rel_neighbors) > 1:
rel_neighbors = [n for _, n in sorted(zip(rel_angles, rel_neighbors),
key=lambda pair: pair[0])]
# add the relevant neighbors to the path and the queue
if ccw_only:
new_path = list(path)
new_path.append(rel_neighbors[0])
queue.append(new_path)
else: # add all neighbors to the search
for neighbor in rel_neighbors:
new_path = list(path)
new_path.append(neighbor)
queue.append(new_path)
explored.append(node)
# if we reached the end of the queue, then no path was found
return None
return node1.key in (n.key for n in node2.adj_lst) and \
node2.key in (n.key for n in node1.adj_lst)

def __repr__(self):
"""Represent PolygonDirectedGraph."""
Expand Down
27 changes: 18 additions & 9 deletions ladybug_geometry_polyskel/polysplit.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ def perimeter_core_subpolygons(
perimeter_sub_polys.extend(_perimeter_sub_polys) # collect perimeter sub-polys

# compute the polygons on the core of the polygon
core_sub_polys = _exterior_cycles_as_polygons(_perimeter_sub_dg)
core_sub_polys = _exterior_cycles_as_polygons(_perimeter_sub_dg, tolerance)
if not flat_core and len(core_sub_polys) != 0: # remake cores w/ holes
core_sub_polys = group_boundaries_and_holes(core_sub_polys)
core_sub_polys = group_boundaries_and_holes(core_sub_polys, tolerance)

return perimeter_sub_polys, core_sub_polys

Expand Down Expand Up @@ -132,7 +132,7 @@ def _split_perimeter_subpolygons(dg, distance, root_key, tol, core_graph=None):
except IndexError: # the last edge of the polygon
next_node = exter_cycle[0]
# find the smallest polygon defined by the exterior node
min_ccw_poly_graph = PolygonDirectedGraph.min_cycle(next_node, base_node)
min_ccw_poly_graph = dg.min_cycle(next_node, base_node)
# offset edge from specified distance, and cut a perimeter polygon
split_poly_graph = _split_polygon_graph(
base_node, next_node, distance, min_ccw_poly_graph, tol)
Expand Down Expand Up @@ -191,20 +191,29 @@ def _split_polygon_graph(node1, node2, distance, poly_graph, tol):
return split_poly_nodes


def _exterior_cycles_as_polygons(dg):
def _exterior_cycles_as_polygons(dg, tol):
"""Convert and sort exterior cycles in a PolygonDirectedGraph to a list of polygons.
Args:
dg: A PolygonDirectedGraph.
tol: The tolerance at which point equivalence is set.
Returns:
A list of Polygon2D objects sorted by area.
"""
ext_cycles = dg.exterior_cycles()
return [Polygon2D([node.pt for node in cycle]) for cycle in ext_cycles]
ext_polygons = []
for cycle in ext_cycles:
ext_poly = Polygon2D([node.pt for node in cycle])
ext_poly = ext_poly.remove_colinear_vertices(tol)
if ext_poly.is_self_intersecting:
ext_polygons.extend(ext_poly.split_through_self_intersection(tol))
else:
ext_polygons.append(ext_poly)
return ext_polygons


def group_boundaries_and_holes(polygons):
def group_boundaries_and_holes(polygons, tol):
"""Group polygons by whether they are contained within another.
Args:
Expand All @@ -230,7 +239,7 @@ def group_boundaries_and_holes(polygons):
# merge the smaller polygons into the larger polygons
merged_polys = []
while len(remain_polys) > 0:
merged_polys.append(_match_holes_to_poly(base_poly, remain_polys))
merged_polys.append(_match_holes_to_poly(base_poly, remain_polys, tol))
if len(remain_polys) > 1:
base_poly = remain_polys[0]
del remain_polys[0]
Expand All @@ -240,7 +249,7 @@ def group_boundaries_and_holes(polygons):
return merged_polys


def _match_holes_to_poly(base_poly, other_polys):
def _match_holes_to_poly(base_poly, other_polys, tol):
"""Attempt to merge other polygons into a base polygon as holes.
Args:
Expand All @@ -257,7 +266,7 @@ def _match_holes_to_poly(base_poly, other_polys):
more_to_check = True
while more_to_check:
for i, r_poly in enumerate(other_polys):
if base_poly.is_polygon_inside(r_poly):
if base_poly.polygon_relationship(r_poly, tol) == 1:
holes.append(r_poly)
del other_polys[i]
break
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ladybug-geometry==1.32.3
ladybug-geometry==1.32.4
Loading

0 comments on commit a73923f

Please sign in to comment.