Skip to content

Commit

Permalink
fix(face): Add methods for joining and simplifying Face3Ds
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswmackey authored and Chris Mackey committed Jul 7, 2023
1 parent a48f239 commit 4060694
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 11 deletions.
6 changes: 3 additions & 3 deletions ladybug_geometry/geometry2d/polygon.py
Original file line number Diff line number Diff line change
Expand Up @@ -1377,7 +1377,7 @@ def overlapping_bounding_rect(polygon1, polygon2, tolerance):
return True # overlap exists

@staticmethod
def joined_intersected_boundary(floor_polys, tolerance):
def joined_intersected_boundary(polygons, tolerance):
"""Get the boundary around several Polygon2D that are touching one another.
This method is faster and more reliable than the gap_crossing_boundary
Expand All @@ -1398,12 +1398,12 @@ def joined_intersected_boundary(floor_polys, tolerance):
and it may be necessary to assess this when interpreting the result.
"""
# intersect the polygons with one another
int_poly = Polygon2D.intersect_polygon_segments(floor_polys, tolerance)
int_poly = Polygon2D.intersect_polygon_segments(polygons, tolerance)

# get indices of all unique vertices across the polygons
vertices = [] # collection of vertices as point objects
poly_indices = [] # collection of polygon indices
for loop in floor_polys:
for loop in int_poly:
ind = []
for v in loop:
found = False
Expand Down
114 changes: 112 additions & 2 deletions ladybug_geometry/geometry3d/face.py
Original file line number Diff line number Diff line change
Expand Up @@ -1631,7 +1631,7 @@ def sub_rects_from_rect_ratio(
ratio is too large for the height, the ratio will take precedence
and the sub-rectangle height will be smaller than this value.
horizontal_separation: A number for the target separation between
individual sub-rectangle centerlines. If this number is larger than
individual sub-rectangle center lines. If this number is larger than
the parent rectangle base, only one sub-rectangle will be produced.
vertical_separation: An optional number to create a single vertical
separation between top and bottom sub-rectangles. The default is
Expand Down Expand Up @@ -1750,7 +1750,7 @@ def sub_rects_from_rect_dimensions(
is too large for the sill_height to fit within the rectangle,
the sub_rect_height will take precedence.
horizontal_separation: A number for the target separation between
individual sub-rectangle centerlines. If this number is larger than
individual sub-rectangle center lines. If this number is larger than
the parent rectangle base, only one sub-rectangle will be produced.
Returns:
Expand Down Expand Up @@ -1921,6 +1921,116 @@ def _from_bool_poly(bool_polygon, plane):
face_3d.append(Face3D(pg_3d[0], plane, holes=pg_3d[1:]))
return face_3d

@staticmethod
def join_coplanar_faces(faces, tolerance):
"""Join a list of coplanar Face3Ds together to get as few as possible.
Args:
faces: A list of Face3D objects to be joined together. These should
all be coplanar but they do not need to have their colinear
vertices removed or be intersected for matching segments along
which they are joined.
tolerance: The maximum difference between values at which point vertices
are considered to be the same.
Returns:
A list of Face3Ds for the minimum number joined together.
"""
# get polygons for the faces that all lie within the same plane
face_polys, base_plane = [], faces[0].plane
for fg in faces:
verts2d = tuple(base_plane.xyz_to_xy(_v) for _v in fg.boundary)
face_polys.append(Polygon2D(verts2d))
if fg.has_holes:
for hole in fg.holes:
verts2d = tuple(base_plane.xyz_to_xy(_v) for _v in hole)
face_polys.append(Polygon2D(verts2d))

# remove colinear vertices
clean_face_polys = []
for geo in face_polys:
try:
clean_face_polys.append(geo.remove_colinear_vertices(tolerance))
except AssertionError: # degenerate geometry to ignore
pass

# get the joined boundaries around the Polygon2D
joined_bounds = Polygon2D.joined_intersected_boundary(
clean_face_polys, tolerance)

# convert the boundary polygons back to Face3D
if len(joined_bounds) == 1: # can be represented with a single Face3D
verts3d = tuple(base_plane.xy_to_xyz(_v) for _v in joined_bounds[0])
return [Face3D(verts3d, plane=base_plane)]
else: # need to separate holes from distinct Face3Ds
bound_faces = []
for poly in joined_bounds:
verts3d = tuple(base_plane.xy_to_xyz(_v) for _v in poly)
bound_faces.append(Face3D(verts3d, plane=base_plane))
return Face3D.merge_faces_to_holes(bound_faces, tolerance)

@staticmethod
def merge_faces_to_holes(faces, tolerance):
"""Take of list of Face3Ds and merge any sub-faces into the others as holes.
This is particularly useful when translating 2D Polygons back into a 3D
space and it is unknown whether certain polygons represent holes in the
others.
Args:
faces: A list of Face3D which will be merged into fewer faces with
any sub-faces represented as holes.
tolerance: The tolerance to be used for evaluating sub-faces.
"""
# sort the faces by area and separate base face from the remaining
faces = sorted(faces, key=lambda x: x.area, reverse=True)
base_face = faces[0]
remain_faces = list(faces[1:])

# merge the smaller faces into the larger faces
merged_face3ds = []
while len(remain_faces) > 0:
merged_face3ds.append(
Face3D._match_holes_to_face(base_face, remain_faces, tolerance))
if len(remain_faces) > 1:
base_face = remain_faces[0]
del remain_faces[0]
elif len(remain_faces) == 1: # lone last Face3D
merged_face3ds.append(remain_faces[0])
del remain_faces[0]
return merged_face3ds

@staticmethod
def _match_holes_to_face(base_face, other_faces, tol):
"""Attempt to merge other faces into a base face as holes.
Args:
base_face: A Face3D to serve as the base.
other_faces: A list of other Face3D objects to attempt to merge into
the base_face as a hole. This method will delete any faces
that are successfully merged into the output from this list.
tol: The tolerance to be used for evaluating sub-faces.
Returns:
A Face3D which has holes in it if any of the other_faces is a valid
sub face.
"""
holes = []
more_to_check = True
while more_to_check:
for i, r_face in enumerate(other_faces):
if base_face.is_sub_face(r_face, tol, 1):
holes.append(r_face)
del other_faces[i]
break
else:
more_to_check = False
if len(holes) == 0:
return base_face
else:
hole_verts = [hole.vertices for hole in holes]
return Face3D(base_face.vertices, Plane(n=Vector3D(0, 0, 1)), hole_verts)

def to_dict(self, include_plane=True, enforce_upper_left=False):
"""Get Face3D as a dictionary.
Expand Down
23 changes: 17 additions & 6 deletions tests/face3d_test.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
# coding=utf-8
import pytest

from ladybug_geometry.geometry2d.pointvector import Vector2D
from ladybug_geometry.geometry3d.pointvector import Point3D, Vector3D
from ladybug_geometry.geometry3d.plane import Plane
from ladybug_geometry.geometry3d.line import LineSegment3D
from ladybug_geometry.geometry3d.ray import Ray3D
from ladybug_geometry.geometry3d.face import Face3D
from ladybug_geometry.geometry2d import Vector2D, Polygon2D
from ladybug_geometry.geometry3d import Point3D, Vector3D, Ray3D, LineSegment3D, \
Plane, Face3D

import math
import json
Expand Down Expand Up @@ -1529,6 +1526,20 @@ def test_coplanar_split():
assert len(split2) == 4


def test_join_coplanar_faces():
"""Test the join_coplanar_faces method."""
geo_file = './tests/json/polygons_for_joined_boundary.json'
with open(geo_file, 'r') as fp:
geo_dict = json.load(fp)
polygons = [Polygon2D.from_dict(p) for p in geo_dict]
faces = [Face3D([Point3D(p.x, p.y, 0) for p in poly]) for poly in polygons]

joined_faces = Face3D.join_coplanar_faces(faces, 0.01)
assert len(joined_faces) == 1
assert joined_faces[0].has_holes
assert len(joined_faces[0].holes) == 5


def test_extract_all_from_stl():
file_path = 'tests/stl/cube_binary.stl'
faces = Face3D.extract_all_from_stl(file_path)
Expand Down

0 comments on commit 4060694

Please sign in to comment.