From d62e66b7c67dc03e47332a640e6afc2b96de07fc Mon Sep 17 00:00:00 2001 From: Chris Mackey Date: Mon, 5 Jun 2023 09:24:24 -0400 Subject: [PATCH] feat(mesh): Add properties for mesh edges (naked and internal) I think this will be helpful in a few places and I am already using it to merge triangulated meshes into cleaner ones. --- ladybug_geometry/_mesh.py | 30 ++++- ladybug_geometry/geometry2d/mesh.py | 72 ++++++++++- ladybug_geometry/geometry3d/face.py | 77 +++++++++--- ladybug_geometry/geometry3d/mesh.py | 73 ++++++++++- ladybug_geometry/geometry3d/polyface.py | 10 +- tests/face3d_test.py | 10 ++ tests/json/tri_hole_face.json | 156 ++++++++++++++++++++++++ tests/mesh2d_test.py | 10 ++ tests/mesh3d_test.py | 10 ++ 9 files changed, 421 insertions(+), 27 deletions(-) create mode 100644 tests/json/tri_hole_face.json diff --git a/ladybug_geometry/_mesh.py b/ladybug_geometry/_mesh.py index 24930d35..c76fb3cd 100644 --- a/ladybug_geometry/_mesh.py +++ b/ladybug_geometry/_mesh.py @@ -29,7 +29,9 @@ class MeshBase(object): * vertex_connected_faces """ __slots__ = ('_vertices', '_faces', '_colors', '_is_color_by_face', - '_area', '_face_areas', '_face_centroids', '_vertex_connected_faces') + '_area', '_face_areas', '_face_centroids', '_vertex_connected_faces', + '_edge_indices', '_edge_types', '_edges', + '_naked_edges', '_internal_edges', '_non_manifold_edges') def __init__(self, vertices, faces, colors=None): """Initialize MeshBase.""" @@ -41,6 +43,12 @@ def __init__(self, vertices, faces, colors=None): self._face_areas = None self._face_centroids = None self._vertex_connected_faces = None + self._edge_indices = None + self._edge_types = None + self._edges = None + self._naked_edges = None + self._internal_edges = None + self._non_manifold_edges = None @property def vertices(self): @@ -295,6 +303,26 @@ def _remove_faces_only(self, pattern): return tuple(_new_faces), _new_colors, _new_f_cent, _new_f_area + def _compute_edge_info(self): + """Compute information about the edges of the mesh faces.""" + edge_i = [] + edge_t = [] + for fi in self.faces: + for i, vi in enumerate(fi): + try: # this can get slow for large number of vertices + ind = edge_i.index((vi, fi[i - 1])) + edge_t[ind] += 1 + except ValueError: # make sure reversed edge isn't there + try: + ind = edge_i.index((fi[i - 1], vi)) + edge_t[ind] += 1 + except ValueError: # add a new edge + if fi[i - 1] != vi: # avoid cases of same start and end + edge_i.append((fi[i - 1], vi)) + edge_t.append(0) + self._edge_indices = edge_i if isinstance(edge_i, tuple) else tuple(edge_i) + self._edge_types = edge_t if isinstance(edge_t, tuple) else tuple(edge_t) + def _transfer_properties(self, new_mesh): """Transfer properties when making a copy of the mesh or doing transforms.""" new_mesh._colors = self._colors diff --git a/ladybug_geometry/geometry2d/mesh.py b/ladybug_geometry/geometry2d/mesh.py index e5b85a96..06303aae 100644 --- a/ladybug_geometry/geometry2d/mesh.py +++ b/ladybug_geometry/geometry2d/mesh.py @@ -38,6 +38,10 @@ class Mesh2D(MeshBase): * face_centroids * face_vertices * vertex_connected_faces + * edges + * naked_edges + * internal_edges + * non_manifold_edges """ __slots__ = ('_min', '_max', '_center', '_centroid') @@ -56,6 +60,12 @@ def __init__(self, vertices, faces, colors=None): self._face_areas = None self._face_centroids = None self._vertex_connected_faces = None + self._edge_indices = None + self._edge_types = None + self._edges = None + self._naked_edges = None + self._internal_edges = None + self._non_manifold_edges = None @classmethod def from_dict(cls, data): @@ -86,7 +96,7 @@ def from_dict(cls, data): @classmethod def from_face_vertices(cls, faces, purge=True): - """Create a mesh from a list of faces with each face defined by a list of Point2Ds. + """Create a mesh from a list of faces with each face defined by Point2Ds. Args: faces: A list of faces with each face defined as a list of 3 or 4 Point2D. @@ -258,6 +268,54 @@ def centroid(self): self._centroid = Point2D(_weight_x / self.area, _weight_y / self.area) return self._centroid + @property + def edges(self): + """"Tuple of all edges in this Mesh3D as LineSegment3D objects.""" + if self._edges is None: + if self._edge_indices is None: + self._compute_edge_info() + self._edges = tuple(LineSegment2D.from_end_points( + self.vertices[seg[0]], self.vertices[seg[1]]) + for seg in self._edge_indices) + return self._edges + + @property + def naked_edges(self): + """"Tuple of all naked edges in this Mesh3D as LineSegment3D objects. + + Naked edges belong to only one face in the mesh (they are not + shared between faces). + """ + if self._naked_edges is None: + self._naked_edges = self._get_edge_type(0) + return self._naked_edges + + @property + def internal_edges(self): + """"Tuple of all internal edges in this Mesh3D as LineSegment3D objects. + + Internal edges are shared between two faces in the mesh. + """ + if self._internal_edges is None: + self._internal_edges = self._get_edge_type(1) + return self._internal_edges + + @property + def non_manifold_edges(self): + """"Tuple of all non-manifold edges in this mesh as LineSegment3D objects. + + Non-manifold edges are shared between three or more faces. + """ + if self._non_manifold_edges is None: + if self._edges is None: + self.edges + nm_edges = [] + for i, type in enumerate(self._edge_types): + if type > 1: + nm_edges.append(self._edges[i]) + self._non_manifold_edges = tuple(nm_edges) + return self._non_manifold_edges + def triangulated(self): """Get a version of this Mesh2D where all quads have been triangulated.""" _new_faces = [] @@ -342,7 +400,7 @@ def remove_faces(self, pattern): return new_mesh, vertex_pattern def remove_faces_only(self, pattern): - """Get a version of this mesh where faces are removed and vertices are not altered. + """Get a version of this mesh where faces are removed and vertices are unaltered. This is faster than the Mesh2D.remove_faces method but will likely result a lower-quality mesh where several vertices exist in the mesh that are not @@ -456,6 +514,16 @@ def _calculate_min_max(self): self._min = Point2D(min_pt[0], min_pt[1]) self._max = Point2D(max_pt[0], max_pt[1]) + def _get_edge_type(self, edge_type): + """Get all of the edges of a certain type in this mesh.""" + if self._edges is None: + self.edges + sel_edges = [] + for i, type in enumerate(self._edge_types): + if type == edge_type: + sel_edges.append(self._edges[i]) + return tuple(sel_edges) + def _face_area(self, face): """Return the area of a face.""" return Mesh2D._get_area(tuple(self._vertices[i] for i in face)) diff --git a/ladybug_geometry/geometry3d/face.py b/ladybug_geometry/geometry3d/face.py index 30b5e7bd..ba5f85f0 100644 --- a/ladybug_geometry/geometry3d/face.py +++ b/ladybug_geometry/geometry3d/face.py @@ -9,6 +9,7 @@ from .pointvector import Point3D, Vector3D from .ray import Ray3D from .line import LineSegment3D +from .polyline import Polyline3D from .plane import Plane from .mesh import Mesh3D from ._2d import Base2DIn3D @@ -934,7 +935,7 @@ def split_through_holes(self): This method attempts to return the minimum number of non-holed shapes that are needed to represent the original Face3D. If this fails, the result - will be a fully-triangulated shape. If getting a minimum number + will be derived from a triangulated shape. If getting a minimum number of constituent Face3D is not important, it is more efficient to just use all of the triangles in Face3D.triangulated_mesh3d instead of the result of this method. @@ -944,8 +945,19 @@ def split_through_holes(self): representation of this Face3D. If this Face3D has no holes a list with a single Face3D is returned. """ + def _shared_vertex_count(vert_set, verts): + """Get the number of shared vertices.""" + in_set = tuple(v for v in verts if v in vert_set) + return len(in_set) + + def _shared_edge_count(edge_set, verts): + """Get the number of shared edges.""" + edges = tuple((verts[i], verts[i - 1]) for i in range(3)) + in_set = tuple(e for e in edges if e in edge_set) + return len(in_set) + if not self.has_holes: - return [self] + return (self,) # check that the direction of vertices for the hole is opposite the boundary boundary = list(self.boundary_polygon2d.vertices) holes = [list(hole.vertices) for hole in self.hole_polygon2d] @@ -953,22 +965,53 @@ def split_through_holes(self): for hole in holes: if Polygon2D._are_clockwise(hole) is bound_direction: hole.reverse() - # split the polygon through the holes + # try to split the polygon neatly in two s_result = Polygon2D._merge_boundary_and_holes(boundary, holes, split=True) - if s_result is None: # return a fully-triangulated shape - tri_faces = [] - tri_mesh = self.triangulated_mesh3d - tri_verts = tri_mesh.vertices - for f in tri_mesh.faces: - f_verts = tuple(tri_verts[pt] for pt in f) - tri_faces.append(Face3D(f_verts, plane=self.plane)) - return tri_faces - poly_1, poly_2 = s_result - vert_1 = tuple(self.plane.xy_to_xyz(pt) for pt in poly_1) - vert_2 = tuple(self.plane.xy_to_xyz(pt) for pt in poly_2) - face_1 = Face3D(vert_1, plane=self.plane) - face_2 = Face3D(vert_2, plane=self.plane) - return face_1, face_2 + if s_result is not None: + poly_1, poly_2 = s_result + vert_1 = tuple(self.plane.xy_to_xyz(pt) for pt in poly_1) + vert_2 = tuple(self.plane.xy_to_xyz(pt) for pt in poly_2) + face_1 = Face3D(vert_1, plane=self.plane) + face_2 = Face3D(vert_2, plane=self.plane) + return face_1, face_2 + # if splitting in two did not work, then triangulate it and merge them together + tri_mesh = self.triangulated_mesh3d + tri_verts = tri_mesh.vertices + rel_f = tri_mesh.faces[0] + tri_faces = [[tuple(tri_verts[pt] for pt in rel_f)]] + tri_face_sets = [set(rel_f)] + tri_edge_sets = [set((rel_f[i - 1], rel_f[i]) for i in range(3))] + faces_to_test = list(tri_mesh.faces[1:]) + # group the faces along matched edges + for f in faces_to_test: + connected = False + for tfs, fs, es in zip(tri_faces, tri_face_sets, tri_edge_sets): + svc = _shared_vertex_count(fs, f) + sec = _shared_edge_count(es, f) + if svc == 2 and sec == 1: # matched edge + tfs.append(tuple(tri_verts[pt] for pt in f)) + for i, v in enumerate(f): + fs.add(v) + es.add((f[i - 1], f[i])) + break + elif svc == 3: # definitely a new shape + connected = True + else: # not ready to be merged; put it to the back + if connected: + tri_faces.append([tuple(tri_verts[pt] for pt in f)]) + tri_face_sets.append(set(f)) + tri_edge_sets.append(set((f[i - 1], f[i]) for i in range(3))) + else: + faces_to_test.append(f) + # create Face3Ds from the triangle groups + final_faces = [] + for tf in tri_faces: + t_mesh = Mesh3D.from_face_vertices(tf) + ed_len = (seg.length for seg in t_mesh.naked_edges) + tol = min(ed_len) / 10 + f_bound = Polyline3D.join_segments(t_mesh.naked_edges, tol) + final_faces.append(Face3D(f_bound[0].vertices, plane=self.plane)) + return final_faces def intersect_line_ray(self, line_ray): """Get the intersection between this face and the input Line3D or Ray3D. diff --git a/ladybug_geometry/geometry3d/mesh.py b/ladybug_geometry/geometry3d/mesh.py index 11bfd386..8753f1d9 100644 --- a/ladybug_geometry/geometry3d/mesh.py +++ b/ladybug_geometry/geometry3d/mesh.py @@ -5,6 +5,7 @@ from .._mesh import MeshBase from ..geometry2d.mesh import Mesh2D from .pointvector import Point3D, Vector3D +from .line import LineSegment3D from .plane import Plane try: @@ -38,6 +39,10 @@ class Mesh3D(MeshBase): * face_normals * vertex_normals * vertex_connected_faces + * edges + * naked_edges + * internal_edges + * non_manifold_edges """ __slots__ = ('_min', '_max', '_center', '_face_normals', '_vertex_normals') @@ -57,6 +62,12 @@ def __init__(self, vertices, faces, colors=None): self._face_normals = None self._vertex_normals = None self._vertex_connected_faces = None + self._edge_indices = None + self._edge_types = None + self._edges = None + self._naked_edges = None + self._internal_edges = None + self._non_manifold_edges = None @classmethod def from_dict(cls, data): @@ -87,7 +98,7 @@ def from_dict(cls, data): @classmethod def from_face_vertices(cls, faces, purge=True): - """Create a mesh from a list of faces with each face defined by a list of Point3Ds. + """Create a mesh from a list of faces with each face defined by Point3Ds. Args: faces: A list of faces with each face defined as a list of 3 or 4 Point3D. @@ -180,6 +191,54 @@ def vertex_normals(self): self._vertex_normals = tuple(self._vertex_normals for face in self.vertices) return self._vertex_normals + @property + def edges(self): + """"Tuple of all edges in this Mesh3D as LineSegment3D objects.""" + if self._edges is None: + if self._edge_indices is None: + self._compute_edge_info() + self._edges = tuple(LineSegment3D.from_end_points( + self.vertices[seg[0]], self.vertices[seg[1]]) + for seg in self._edge_indices) + return self._edges + + @property + def naked_edges(self): + """"Tuple of all naked edges in this Mesh3D as LineSegment3D objects. + + Naked edges belong to only one face in the mesh (they are not + shared between faces). + """ + if self._naked_edges is None: + self._naked_edges = self._get_edge_type(0) + return self._naked_edges + + @property + def internal_edges(self): + """"Tuple of all internal edges in this Mesh3D as LineSegment3D objects. + + Internal edges are shared between two faces in the mesh. + """ + if self._internal_edges is None: + self._internal_edges = self._get_edge_type(1) + return self._internal_edges + + @property + def non_manifold_edges(self): + """"Tuple of all non-manifold edges in this mesh as LineSegment3D objects. + + Non-manifold edges are shared between three or more faces. + """ + if self._non_manifold_edges is None: + if self._edges is None: + self.edges + nm_edges = [] + for i, type in enumerate(self._edge_types): + if type > 1: + nm_edges.append(self._edges[i]) + self._non_manifold_edges = tuple(nm_edges) + return self._non_manifold_edges + def remove_vertices(self, pattern): """Get a version of this mesh where vertices are removed according to a pattern. @@ -238,7 +297,7 @@ def remove_faces(self, pattern): return new_mesh, vertex_pattern def remove_faces_only(self, pattern): - """Get a version of this mesh where faces are removed and vertices are not altered. + """Get a version of this mesh where faces are removed and vertices are unaltered. This is faster than the Mesh3D.remove_faces method but will likely result a lower-quality mesh where several vertices exist in the mesh that are not @@ -472,6 +531,16 @@ def _calculate_vertex_normals(self): vn.append(_v.normalize()) self._vertex_normals = tuple(vn) + def _get_edge_type(self, edge_type): + """Get all of the edges of a certain type in this mesh.""" + if self._edges is None: + self.edges + sel_edges = [] + for i, type in enumerate(self._edge_types): + if type == edge_type: + sel_edges.append(self._edges[i]) + return tuple(sel_edges) + def _tri_face_centroid(self, face): """Compute the centroid of a triangular face.""" return Mesh3D._tri_centroid(tuple(self._vertices[i] for i in face)) diff --git a/ladybug_geometry/geometry3d/polyface.py b/ladybug_geometry/geometry3d/polyface.py index bad47403..bece2264 100644 --- a/ladybug_geometry/geometry3d/polyface.py +++ b/ladybug_geometry/geometry3d/polyface.py @@ -16,7 +16,7 @@ class Polyface3D(Base2DIn3D): - """Object with Multiple Planar Faces in 3D Space. Includes solid objects and polyhedra. + """Object with Multiple Planar Faces in 3D Space. Includes solids and polyhedra. Args: vertices: A list of Point3D objects representing the vertices of @@ -384,7 +384,7 @@ def edge_types(self): @property def edge_information(self): - """Dictionary with keys: 'edge_indices', 'edge_types' and corresponding properties. + """Dictionary with keys edge_indices, edge_types and corresponding properties. """ return {'edge_indices': self._edge_indices, 'edge_types': self._edge_types} @@ -419,7 +419,7 @@ def is_solid(self): return self._is_solid def merge_overlapping_edges(self, tolerance, angle_tolerance): - """Get this object with overlapping naked edges merged into single internal edges. + """Get this object with overlapping naked edges merged into single internal edges This can be used to determine if a polyface is truly solid. The default test of edge conditions that runs upon creation of a polyface does @@ -609,7 +609,7 @@ def rotate_xy(self, angle, origin): return _new_pface def reflect(self, normal, origin): - """Get a polyface reflected across a plane with the input normal vector and origin. + """Get a polyface reflected across a plane with the input normal and origin. Args: normal: A Vector3D representing the normal vector for the plane across @@ -671,7 +671,7 @@ def is_point_inside(self, point, test_vector=Vector3D(1, 0, 0)): return True def does_intersect_line_ray_exist(self, line_ray): - """Boolean denoting whether an intersection exists between the input Line3D or Ray3D. + """Boolean for whether an intersection exists between the input Line3D or Ray3D. Args: line_ray: A Line3D or Ray3D object for which intersection will be evaluated. diff --git a/tests/face3d_test.py b/tests/face3d_test.py index be2306b0..4910e245 100644 --- a/tests/face3d_test.py +++ b/tests/face3d_test.py @@ -389,6 +389,16 @@ def test_face3d_split_through_holes(): assert len(face_1.vertices) + len(face_2.vertices) == 18 +def test_face3d_split_through_holes_detailed(): + """Test the Face3D split_through_holes method with a detailed Face3D.""" + geo_file = './tests/json/tri_hole_face.json' + with open(geo_file, 'r') as fp: + geo_dict = json.load(fp) + face_1 = Face3D.from_dict(geo_dict) + s_faces = face_1.split_through_holes() + assert 2 < len(s_faces) < 20 + + def test_face3d_init_h_shape(): """Test the initialization of Face3D that is H-shaped.""" geo_file = './tests/json/h_shaped_face.json' diff --git a/tests/json/tri_hole_face.json b/tests/json/tri_hole_face.json new file mode 100644 index 00000000..8cabc374 --- /dev/null +++ b/tests/json/tri_hole_face.json @@ -0,0 +1,156 @@ +{ + "type": "Face3D", + "plane": { + "o": [ + -12.464844726110787, + 64.344569934333464, + 8.4327999999999985 + ], + "n": [ + 0.0, + 0.0, + 1.0 + ], + "type": "Plane", + "x": [ + 1.0, + 0.0, + 0.0 + ] + }, + "boundary": [ + [ + -12.464844726110787, + 64.344569934333464, + 8.4327999999999985 + ], + [ + -12.464844726110796, + 59.082007434332354, + 8.4327999999999985 + ], + [ + 24.844069665934509, + 59.082007434332226, + 8.4327999999999985 + ], + [ + 28.84983252554035, + 61.41870243576939, + 8.4327999999999985 + ], + [ + 28.849832525540389, + 89.07940743433366, + 8.4327999999999985 + ], + [ + -32.999167476298553, + 89.079407434333859, + 8.4327999999999985 + ], + [ + -32.999167476298602, + 59.082007434332425, + 8.4327999999999985 + ], + [ + -15.452519726110795, + 59.082007434332354, + 8.4327999999999985 + ], + [ + -15.452519726110786, + 64.344569934333464, + 8.4327999999999985 + ] + ], + "holes": [ + [ + [ + -2.0922371580855947, + 78.521400583883462, + 8.4327999999999985 + ], + [ + -2.0922371580855841, + 83.687125583883457, + 8.4327999999999985 + ], + [ + 0.45728784191442928, + 83.687125583883599, + 8.4327999999999985 + ], + [ + 0.45728784191441862, + 78.521400583883462, + 8.4327999999999985 + ] + ], + [ + [ + 8.6789073757639787, + 78.988747964230981, + 8.4327999999999985 + ], + [ + 19.529469875763926, + 78.988747964230924, + 8.4327999999999985 + ], + [ + 19.529469875763933, + 74.116710464231133, + 8.4327999999999985 + ], + [ + 16.964069875763933, + 74.116710464231147, + 8.4327999999999985 + ], + [ + 16.964069875763933, + 73.72777296423115, + 8.4327999999999985 + ], + [ + 13.560469875763909, + 73.72777296423115, + 8.4327999999999985 + ], + [ + 10.567670831713984, + 71.466694386242921, + 8.4327999999999985 + ], + [ + 8.6789073757639716, + 74.448497964231152, + 8.4327999999999985 + ] + ], + [ + [ + -4.0225181040862275, + 67.757411970229782, + 8.4327999999999985 + ], + [ + -4.0225181040862275, + 72.862811970229828, + 8.4327999999999985 + ], + [ + 4.3340818959137604, + 72.862811970229828, + 8.4327999999999985 + ], + [ + 4.3340818959137604, + 67.757411970229782, + 8.4327999999999985 + ] + ] + ] +} \ No newline at end of file diff --git a/tests/mesh2d_test.py b/tests/mesh2d_test.py index c88d2fdf..ac6ff1db 100644 --- a/tests/mesh2d_test.py +++ b/tests/mesh2d_test.py @@ -39,6 +39,11 @@ def test_mesh2d_init(): for vf in mesh.vertex_connected_faces: assert len(vf) == 1 + assert len(mesh.edges) == 4 + assert len(mesh.naked_edges) == 4 + assert len(mesh.internal_edges) == 0 + assert len(mesh.non_manifold_edges) == 0 + mesh.colors = [] assert mesh.colors is None @@ -144,6 +149,11 @@ def test_mesh2d_init_two_faces(): assert mesh._is_color_by_face is False assert mesh.colors is None + assert len(mesh.edges) == 6 + assert len(mesh.naked_edges) == 5 + assert len(mesh.internal_edges) == 1 + assert len(mesh.non_manifold_edges) == 0 + def test_mesh2d_init_from_face_vertices(): """Test the initialization of Mesh2D from_face_vertices.""" diff --git a/tests/mesh3d_test.py b/tests/mesh3d_test.py index 447ec71a..891cd1a1 100644 --- a/tests/mesh3d_test.py +++ b/tests/mesh3d_test.py @@ -40,6 +40,11 @@ def test_mesh3d_init(): for vf in mesh.vertex_connected_faces: assert len(vf) == 1 + assert len(mesh.edges) == 4 + assert len(mesh.naked_edges) == 4 + assert len(mesh.internal_edges) == 0 + assert len(mesh.non_manifold_edges) == 0 + mesh.colors = [] assert mesh.colors is None @@ -143,6 +148,11 @@ def test_mesh3d_init_two_faces(): assert mesh._is_color_by_face is False assert mesh.colors is None + assert len(mesh.edges) == 6 + assert len(mesh.naked_edges) == 5 + assert len(mesh.internal_edges) == 1 + assert len(mesh.non_manifold_edges) == 0 + def test_mesh3d_init_from_face_vertices(): """Test the initialization of Mesh3D from_face_vertices."""