Skip to content

Commit

Permalink
fix(face): Improve the face splitting methods
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswmackey committed Aug 29, 2024
1 parent ec0c4c8 commit 06a0896
Show file tree
Hide file tree
Showing 3 changed files with 65 additions and 34 deletions.
39 changes: 16 additions & 23 deletions ladybug_geometry/geometry3d/face.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ._2d import Base2DIn3D

from ..intersection3d import closest_point3d_on_line3d
from ..network import DirectedGraphNetwork

from ..geometry2d.pointvector import Point2D, Vector2D
from ..geometry2d.ray import Ray2D
Expand Down Expand Up @@ -1197,42 +1198,34 @@ def split_with_line(self, line, tolerance):
if self.plane.distance_to_point(line.p1) > tolerance or \
self.plane.distance_to_point(line.p1) > tolerance:
return None
# extend the endpoints of the line so that tolerance will split it
tvc = line.v.normalize() * tolerance
line = LineSegment3D.from_end_points(line.p1.move(-tvc), line.p2.move(tvc))

# 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
line_2d = LineSegment2D.from_end_points(
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 or intersect_count % 2 != 0:
if intersect_count == 0:
return None

# get BooleanPolygons of the polygon and the line segment
move_vec = line_2d.v.rotate(math.pi / 2).normalize() * (tolerance / 2)
line_verts = (line_2d.p1, line_2d.p2, line_2d.p2.move(move_vec),
line_2d.p1.move(move_vec))
line_poly = [(pb.BooleanPoint(pt.x, pt.y) for pt in line_verts)]
face_polys = [(pb.BooleanPoint(pt.x, pt.y) for pt in bnd_poly.vertices)]
if self.has_holes:
for hole in self.hole_polygon2d:
face_polys.append((pb.BooleanPoint(pt.x, pt.y) for pt in hole.vertices))
b_poly1 = pb.BooleanPolygon(face_polys)
b_poly2 = pb.BooleanPolygon(line_poly)

# split the two boolean polygons with one another
int_tol = tolerance / 100
try:
poly1_result = pb.difference(b_poly1, b_poly2, int_tol)
except Exception:
return None # typically a tolerance issue causing failure
# create the network object and use it to find the cycles
dg = DirectedGraphNetwork.from_shape_to_split(
bnd_poly, hole_polys, [line_2d], 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
return Face3D._from_bool_poly(poly1_result, prim_pl, tolerance)
if len(split_faces) == 1:
return split_faces
return Face3D.merge_faces_to_holes(split_faces, tolerance)

def split_with_polyline(self, polyline, tolerance):
"""Split this face into two or more Face3D given an open Polyline3D.
Expand Down
57 changes: 47 additions & 10 deletions ladybug_geometry/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,8 @@ def min_cycle(self, base_node, goal_node, ccw_only=False):
# set up a queue for exploring the graph
explored = []
queue = [[base_node]]
orig_dir = base_node.pt - goal_node.pt # yields a vector
orig_dir = base_node.pt - goal_node.pt \
if base_node.key != goal_node.key else None
# loop to traverse the graph with the help of the queue
while queue:
path = queue.pop(0)
Expand All @@ -504,16 +505,23 @@ def min_cycle(self, base_node, goal_node, ccw_only=False):
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 = [], []
last_resort_neighbors, last_resort_angles = [], []
for neighbor in node.adj_lst:
if neighbor == goal_node: # the shortest path was found!
path.append(goal_node)
return path
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)
cw_angle = prev_dir.angle_clockwise(edge_dir * -1) \
if prev_dir is not None else math.pi
if 1e-5 < cw_angle < (2 * math.pi) - 1e-5:
rel_neighbors.append(neighbor)
rel_angles.append(cw_angle)
else: # try to avoid back-tracking along the search
last_resort_neighbors.append(neighbor)
last_resort_angles.append(cw_angle)
if len(rel_neighbors) == 0: # back tracking is the only option
rel_neighbors = last_resort_neighbors
rel_angles = last_resort_angles
# sort the neighbors by clockwise angle
if len(rel_neighbors) > 1:
rel_neighbors = [n for _, n in sorted(zip(rel_angles, rel_neighbors),
Expand Down Expand Up @@ -551,13 +559,27 @@ class methods that work from polygons. If the DirectedGraphNetwork was made
iter_count = 0
max_iter = len(self.nodes)
remaining_nodes = self.ordered_nodes
while len(remaining_nodes) != 0 or iter_count > max_iter:
explored_nodes = set()
while len(remaining_nodes) > 1 or iter_count > max_iter:
cycle_root = remaining_nodes[0]
min_cycle = self.min_cycle(cycle_root, cycle_root, True)
next_node = cycle_root
ext_cycle = False
if cycle_root.exterior: # exterior cycles tend to be easier to find
next_node = DirectedGraphNetwork.next_exterior_node(cycle_root)
if next_node is not None:
ext_cycle = True
else:
next_node = cycle_root

# 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(cycle_root, cycle_root, False)
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:
min_cycle.pop(-1) # take out the last duplicated node
if not ext_cycle:
min_cycle.pop(-1) # take out the last duplicated node
is_valid_cycle = True
for node in min_cycle:
node_cycle_counts[node.key] = node_cycle_counts[node.key] - 1
Expand All @@ -569,8 +591,17 @@ class methods that work from polygons. If the DirectedGraphNetwork was made
elif node_cycle_counts[node.key] < 0: # not a valid cycle
node_cycle_counts[node.key] = 0
is_valid_cycle = False
# add the valid cycle to the list to be returned
if is_valid_cycle:
all_cycles.append(min_cycle)
# reorder the remaining nodes so unexplored nodes get prioritized
for node in min_cycle:
explored_nodes.add(node.key)
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
return all_cycles

Expand Down Expand Up @@ -763,6 +794,12 @@ def _intersect_segments(segments, additional_segments, tolerance):
A list of LineSegment2D for the input segments split through
self-intersection and intersection with the additional_segments.
"""
# make sure that we are working with lists
if not isinstance(segments, list):
segments = list(segments)
if not isinstance(additional_segments, list):
additional_segments = list(additional_segments)

# extend segments a little to ensure intersections happen
under_tol = tolerance * 0.99
ext_segments = []
Expand Down
3 changes: 2 additions & 1 deletion tests/face3d_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1142,7 +1142,8 @@ def test_split_with_line():
l_pts = (Point3D(1, 0, 2), Point3D(1, 1, 2))
line = LineSegment3D.from_end_points(*l_pts)
int_result = face.split_with_line(line, 0.01)
assert int_result is None
assert int_result is None or \
(len(int_result) == 1 and int_result[0].area == pytest.approx(face.area, rel=1e-2))

l_pts = (Point3D(-1, -1, 2), Point3D(3, 3, 2))
line = LineSegment3D.from_end_points(*l_pts)
Expand Down

0 comments on commit 06a0896

Please sign in to comment.