Skip to content

Commit

Permalink
fix(network): Improve network to handle fully interior cycles
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswmackey committed Aug 30, 2024
1 parent ccc6a3e commit 486a359
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 9 deletions.
73 changes: 69 additions & 4 deletions ladybug_geometry/geometry3d/face.py
Original file line number Diff line number Diff line change
Expand Up @@ -1207,9 +1207,6 @@ def split_with_line(self, line, tolerance):
prim_pl.xyz_to_xy(line.p1), prim_pl.xyz_to_xy(line.p2))
if not Polygon2D.overlapping_bounding_rect(bnd_poly, line_2d, tolerance):
return None
intersect_count = len(bnd_poly.intersect_line_ray(line_2d))
if intersect_count == 0:
return None

# create the network object and use it to find the cycles
dg = DirectedGraphNetwork.from_shape_to_split(
Expand Down Expand Up @@ -1263,10 +1260,14 @@ def split_with_polyline(self, polyline, tolerance):
polyline_2d = Polyline2D([prim_pl.xyz_to_xy(pt) for pt in polyline])
if not Polygon2D.overlapping_bounding_rect(bnd_poly, polyline_2d, tolerance):
return None
rel_line_2ds = []
intersect_count = 0
for seg in polyline_2d.segments:
intersect_count += len(bnd_poly.intersect_line_ray(seg))
if intersect_count == 0:
if seg.length > tolerance and \
Polygon2D.overlapping_bounding_rect(bnd_poly, seg, tolerance):
rel_line_2ds.append(seg)
if len(rel_line_2ds) == 0:
return None

# create the network object and use it to find the cycles
Expand All @@ -1285,6 +1286,70 @@ def split_with_polyline(self, polyline, tolerance):
return split_faces
return Face3D.merge_faces_to_holes(split_faces, tolerance)

def split_with_lines(self, lines, tolerance):
"""Split this face into two or more Face3D given multiple LineSegment3D.
Using this method is distinct from looping over Face3D.split_with_line
in that this method will resolve cases where multiple segments branch out
from nodes in a network of input lines. So, if three line segments
meet at a point in the middle of this Face3D and each extend past the
edges of this Face3D, this method can split the Face3D in 3 parts whereas
looping over the Face3D.split_with_line will not do this given that each
individual segment cannot split the Face3D.
If the input lines together do not intersect this Face3D in a manner
that splits it into two or more pieces, None will be returned.
Args:
lines: A list of LineSegment3D objects in the plane of this Face3D,
which will be used to split it into two or more pieces.
tolerance: The maximum difference between point values for them to be
considered distinct from one another.
Returns:
A list of Face3D for the result of splitting this Face3D with the
input lines. Will be None if the line is not in the plane of the
Face3D or if it does not split the Face3D into two or more pieces.
"""
# first check that the lines are in the plane of the Face3D
rel_line_3ds = []
for line in lines:
if self.plane.distance_to_point(line.p1) <= tolerance or \
self.plane.distance_to_point(line.p1) <= tolerance:
rel_line_3ds.append(line)
if len(rel_line_3ds) == 0:
return None

# change the line and face to be in 2D and check that it can split the Face
prim_pl = self.plane
bnd_poly = self.boundary_polygon2d
hole_polys = self.hole_polygon2d
rel_line_2ds = []
for line in rel_line_3ds:
line_2d = LineSegment2D.from_end_points(
prim_pl.xyz_to_xy(line.p1), prim_pl.xyz_to_xy(line.p2))
if line_2d.length > tolerance and \
Polygon2D.overlapping_bounding_rect(bnd_poly, line_2d, tolerance):
rel_line_2ds.append(line_2d)
if len(rel_line_2ds) == 0:
return None

# create the network object and use it to find the cycles
dg = DirectedGraphNetwork.from_shape_to_split(
bnd_poly, hole_polys, rel_line_2ds, tolerance)
split_faces = []
for cycle in dg.all_min_cycles():
if len(cycle) >= 3:
pt_3ds = [prim_pl.xy_to_xyz(node.pt) for node in cycle]
new_face = Face3D(pt_3ds, plane=prim_pl)
new_face = new_face.remove_colinear_vertices(tolerance)
split_faces.append(new_face)

# rebuild the Face3D from the results and return them
if len(split_faces) == 1:
return split_faces
return Face3D.merge_faces_to_holes(split_faces, tolerance)

def intersect_line_ray(self, line_ray):
"""Get the intersection between this face and the input LineSegment3D or Ray3D.
Expand Down
41 changes: 36 additions & 5 deletions ladybug_geometry/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class Node(object):
pt: A Point2D object for the node
key: String representation of the Point2D object which accounts for tolerance.
order: Integer for the order of the Node (based on directed graph propagation).
adj_lst: List of strings representing keys adjacent to this node.
adj_lst: List of Node objects that are adjacent to this node.
exterior: Optional boolean to indicate if the Node is on the exterior of
the graph. If None, this value can be computed later based on the
position within the overall graph.
Expand Down Expand Up @@ -560,24 +560,31 @@ class methods that work from polygons. If the DirectedGraphNetwork was made
max_iter = len(self.nodes)
remaining_nodes = self.ordered_nodes
explored_nodes = set()
while len(remaining_nodes) > 1 or iter_count > max_iter:
while len(remaining_nodes) > 1 and iter_count < max_iter:
# try to identify two connected nodes which we can use to build a cycle
cycle_root = remaining_nodes[0]
next_node = cycle_root
next_node = cycle_root # if we can't find a connected node, connect to self
ext_cycle = False
if cycle_root.exterior: # exterior cycles tend to be easier to find
if cycle_root.exterior: # exterior cycles tend to have clear connections
next_node = DirectedGraphNetwork.next_exterior_node(cycle_root)
if next_node is not None:
ext_cycle = True
else:
next_node = cycle_root
if not ext_cycle: # see if we can connect it to another incomplete node
for _next_node in cycle_root.adj_lst:
if node_cycle_counts[_next_node.key] != 0:
next_node = _next_node
ext_cycle = True
break

# find the minimum cycle by first searching counter-clockwise; then all over
min_cycle = self.min_cycle(next_node, cycle_root, True)
if min_cycle is None: # try it without the CCW restriction
min_cycle = self.min_cycle(next_node, cycle_root, False)

# if we found a minimum cycle, evaluate its validity by node connections
if min_cycle is not None:
if min_cycle is not None and len(min_cycle) >= 3:
if not ext_cycle:
min_cycle.pop(-1) # take out the last duplicated node
is_valid_cycle = True
Expand All @@ -602,7 +609,31 @@ class methods that work from polygons. If the DirectedGraphNetwork was made
if node.key not in explored_nodes:
break
remaining_nodes.insert(0, remaining_nodes.pop(j))
elif len(min_cycle) == 2:
if len(remaining_nodes) != 0:
for j, node in enumerate(remaining_nodes):
if node.key not in explored_nodes:
break
remaining_nodes.insert(0, remaining_nodes.pop(j))
iter_count += 1

# if we wer not able to address all nodes, see if they are all in the same loop
if len(remaining_nodes) >= 3:
current_node = remaining_nodes.pop(0)
current_node_adj = [node.key for node in node.adj_lst]
last_cycle = [current_node]
iter_count, max_iter = 0, len(remaining_nodes)
while len(remaining_nodes) > 0 and iter_count < max_iter:
for k, node in enumerate(remaining_nodes):
if node.key in current_node_adj:
current_node = remaining_nodes.pop(k)
current_node_adj = [node.key for node in node.adj_lst]
last_cycle.append(current_node)
break
iter_count += 1
if len(last_cycle) > 2:
all_cycles.append(last_cycle)

return all_cycles

def exterior_cycle(self, cycle_root):
Expand Down
58 changes: 58 additions & 0 deletions tests/face3d_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1191,6 +1191,64 @@ def test_split_with_polyline():
(len(int_result) == 1 and int_result[0].area == pytest.approx(face.area, rel=1e-2))


def test_split_with_lines():
"""Test the split_with_line method."""
f_pts = (Point3D(0, 0, 2), Point3D(2, 0, 2), Point3D(2, 2, 2), Point3D(0, 2, 2))
face = Face3D(f_pts)

# perform a test with a T shape
l_pts1 = (Point3D(1, -1, 2), Point3D(1, 1, 2))
line1 = LineSegment3D.from_end_points(*l_pts1)
l_pts2 = (Point3D(-1, 1, 2), Point3D(1, 1, 2))
line2 = LineSegment3D.from_end_points(*l_pts2)
l_pts3 = (Point3D(1, 1, 2), Point3D(3, 3, 2))
line3 = LineSegment3D.from_end_points(*l_pts3)
all_lines = [line1, line2, line3]
int_result = face.split_with_lines(all_lines, 0.01)

assert len(int_result) == 3
for int_f in int_result:
assert int_f.area == pytest.approx(face.area * 0.25, rel=1e-2) or \
int_f.area == pytest.approx(face.area * 0.375, rel=1e-2)

# perform a test with a cross shape
l_pts1 = (Point3D(1, -1, 2), Point3D(1, 1, 2))
line1 = LineSegment3D.from_end_points(*l_pts1)
l_pts2 = (Point3D(-1, 1, 2), Point3D(1, 1, 2))
line2 = LineSegment3D.from_end_points(*l_pts2)
l_pts3 = (Point3D(1, 1, 2), Point3D(3, 1, 2))
line3 = LineSegment3D.from_end_points(*l_pts3)
l_pts4 = (Point3D(1, 1, 2), Point3D(1, 3, 2))
line4 = LineSegment3D.from_end_points(*l_pts4)
all_lines = [line1, line2, line3, line4]
int_result = face.split_with_lines(all_lines, 0.01)

assert len(int_result) == 4
for int_f in int_result:
assert int_f.area == pytest.approx(face.area * 0.25, rel=1e-2)


def test_split_with_lines_hashtag():
"""Test the split_with_lines method using a hashtag shape."""
f_pts = (Point3D(0, 0, 2), Point3D(2, 0, 2), Point3D(2, 2, 2), Point3D(0, 2, 2))
face = Face3D(f_pts)

# perform a test with a hashtag shape
l_pts1 = (Point3D(-1, 0.5, 2), Point3D(3, 0.5, 2))
line1 = LineSegment3D.from_end_points(*l_pts1)
l_pts2 = (Point3D(-1, 1.5, 2), Point3D(3, 1.5, 2))
line2 = LineSegment3D.from_end_points(*l_pts2)
l_pts3 = (Point3D(0.5, -1, 2), Point3D(0.5, 3, 2))
line3 = LineSegment3D.from_end_points(*l_pts3)
l_pts4 = (Point3D(1.5, -1, 2), Point3D(1.5, 3, 2))
line4 = LineSegment3D.from_end_points(*l_pts4)
all_lines = [line1, line2, line3, line4]
int_result = face.split_with_lines(all_lines, 0.01)

assert len(int_result) == 9
assert sum(int_f.area for int_f in int_result) == pytest.approx(face.area, rel=1e-2)


def test_intersect_line_ray():
"""Test the Face3D intersect_line_ray method."""
pts = (Point3D(0, 0, 2), Point3D(2, 0, 2), Point3D(2, 1, 2), Point3D(1, 1, 2),
Expand Down

0 comments on commit 486a359

Please sign in to comment.